Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 638c544bd1 | |||
| 79b5bab273 | |||
| b2920f0baf | |||
| 4be8dd9879 | |||
| 801c936fd4 | |||
| d4836f9e25 | |||
| 75192db663 | |||
| ef9afb0d37 | |||
| a5769f96cd | |||
| 2c6f513aed | |||
| bfd4af2367 | |||
| 2177e1cfa0 | |||
| 5cd609cea7 | |||
| 226a6eef0a | |||
| 1caec99b29 | |||
| e18c1dd213 | |||
| d9e8a33aa4 | |||
| 4be4cc0ed0 | |||
| 4a5c9e990c | |||
| c09e170bdd | |||
| c1082923ee | |||
| 1c292e4936 | |||
| d6f0f34fe9 | |||
| 75789f620d | |||
| a9ca16eb42 | |||
| 987173b52f | |||
| 81ab857a6f | |||
| e8b4620a96 | |||
| 2b0e4c9623 | |||
| c9ba7f839c | |||
| b4ce7c713b | |||
| 816c81c12e | |||
| 92d25446d6 | |||
| e73a5b0116 |
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: styfle/cancel-workflow-action@0.6.0
|
||||
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
|
||||
with:
|
||||
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Update description with coverage
|
||||
uses: kt3k/update-pr-description@v1.0.1
|
||||
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
|
||||
with:
|
||||
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||
pr_title: "chore: Update translations from Crowdin"
|
||||
|
||||
@@ -13,16 +13,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
semantic:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
working-directory: packages/excalidraw
|
||||
env:
|
||||
CI: true
|
||||
- uses: andresz1/size-limit-action@v1
|
||||
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_script: build:esm
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
run: yarn test:coverage
|
||||
- name: "Report Coverage"
|
||||
if: always() # Also generate the report if tests are failing
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widget=false"/></a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://twitter.com/excalidraw">
|
||||
|
||||
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
startArrowhead: "circle",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Transactions
|
||||
|
||||
Transactions let many live scene updates render immediately while still committing a single durable history entry.
|
||||
|
||||
Typical use case:
|
||||
|
||||
- AI streaming emits many `NEVER` updates for real-time feedback.
|
||||
- Those updates advance the live scene and store snapshot, but do not create undo history.
|
||||
- A transaction records the intended logical delta separately, then commits one synthetic durable increment at the end.
|
||||
|
||||
## Key Files
|
||||
|
||||
### `packages/excalidraw/transaction/`
|
||||
|
||||
- `transaction.ts`
|
||||
- `Transaction` is the per-tx instance.
|
||||
- `updateScene()` applies live updates with `captureUpdate: NEVER`.
|
||||
- `updateElements()` is a typed partial-patch helper over `updateScene()`.
|
||||
- `commit()` builds synthetic snapshots and commits one durable entry.
|
||||
- `cancel()` ends the tx without committing history.
|
||||
- `ledger.ts`
|
||||
- `TransactionLedger` keeps per-element baseline, target, and touched props across tx steps.
|
||||
- `buildSyntheticSnapshots()` reconciles tx intent with the live scene using `live-wins-per-prop`.
|
||||
- `undoOverridePlanner.ts`
|
||||
- Tracks tx intermediate values.
|
||||
- Produces undo override candidates for regular user history entries that were recorded while a tx was active.
|
||||
- `manager.ts`
|
||||
- Owns tx lifecycle state and active-tx priority.
|
||||
- Injects tx undo markers into durable deltas at record time.
|
||||
- Resolves effective history deltas at undo/redo time.
|
||||
- `diff.ts` / `types.ts`
|
||||
- Shared diff helpers and transaction-specific types.
|
||||
|
||||
### `packages/element/src/store.ts`
|
||||
|
||||
- `store.commitSyntheticIncrement()`
|
||||
- Builds `StoreSnapshot -> StoreChange -> StoreDelta` from logical before/after snapshots.
|
||||
- Commits the synthetic durable increment immediately through an isolated path.
|
||||
- Does not flush unrelated pending micro actions.
|
||||
- `StoreDelta.markers`
|
||||
- Carries extra history markers such as `txUndoOverrides`.
|
||||
|
||||
### `packages/excalidraw/history.ts`
|
||||
|
||||
- `onBeforeRecord()`
|
||||
- Lets the transaction manager attach markers before a durable delta becomes a history entry.
|
||||
- `setEffectiveDeltaResolver()`
|
||||
- Lets the transaction manager rewrite a history delta at undo/redo time before it is applied.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Caller creates a transaction.
|
||||
2. `tx.updateScene()` applies live updates with `NEVER`.
|
||||
3. The ledger records net element changes; appState intent is accumulated separately.
|
||||
4. Regular user durable edits that happen while the tx is active may receive tx undo markers.
|
||||
5. `tx.commit()` asks the ledger for logical before/after snapshots and calls `store.commitSyntheticIncrement()`.
|
||||
6. Undo/redo later uses the manager's effective-delta resolver to decide whether ended tx markers should patch the baseline of a regular history entry.
|
||||
|
||||
Result:
|
||||
|
||||
- live rendering stays immediate;
|
||||
- one tx still produces one durable history entry;
|
||||
- interleaved regular edits can undo back to the correct pre-tx baseline.
|
||||
|
||||
## Undo Examples
|
||||
|
||||
### Interleaved Regular Edit
|
||||
|
||||
1. Baseline: `rect = (x: 0, stroke: black)`
|
||||
2. Tx updates it live to `(x: 200, stroke: red)` with `NEVER`
|
||||
3. User makes a regular durable edit: `(x: 200, stroke: purple)`
|
||||
4. Tx commits; live stays purple
|
||||
|
||||
The user history entry was recorded while the tx was active, so its local baseline is `red`.
|
||||
If undo later runs after the tx has ended, restoring `red` is wrong: the correct pre-tx baseline is `black`.
|
||||
|
||||
To fix this:
|
||||
|
||||
- record-time attaches a tx undo marker to the user history entry
|
||||
- perform-time checks whether the tx has already ended
|
||||
- if ended, the effective delta is patched so undo restores `black` instead of `red`
|
||||
|
||||
### Active Tx vs Ended Tx
|
||||
|
||||
Using the same example:
|
||||
|
||||
- if the user undoes while the tx is still active, the marker stays inactive and undo restores the tx intermediate value (`red`)
|
||||
- if the user undoes after the tx has ended, the marker becomes active and undo restores the pre-tx baseline (`black`)
|
||||
|
||||
So markers are recorded early, but applied conditionally.
|
||||
|
||||
## AppState
|
||||
|
||||
Elements use ledger snapshots. AppState does not.
|
||||
|
||||
The transaction instead keeps:
|
||||
|
||||
- `initialAppState`: observed appState at tx start
|
||||
- `accumulatedAppState`: merged caller intent from `tx.updateScene({ appState })`
|
||||
|
||||
At commit, the transaction computes the final appState patch and passes it into `store.commitSyntheticIncrement()`.
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
const tx = app.transactionManager.create();
|
||||
|
||||
tx.updateScene({ elements: updatedElementsA });
|
||||
tx.updateScene({ elements: updatedElementsB });
|
||||
tx.updateElements({
|
||||
elements: [
|
||||
{ id: "rect-1", type: "rectangle", updates: { strokeColor: "#f00" } },
|
||||
{ id: "rect-2", type: "rectangle", updates: { x: 120 } },
|
||||
],
|
||||
});
|
||||
|
||||
tx.commit();
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `commit()` and `cancel()` are idempotent.
|
||||
- `updateScene()` and `updateElements()` throw after the tx has ended.
|
||||
- Conflict handling inside synthetic snapshots is fixed to `live-wins-per-prop`.
|
||||
- Multi-tx interleaving is supported through manager-owned lifecycle tracking and active-tx priority.
|
||||
@@ -97,8 +97,8 @@ const config = {
|
||||
href: "https://discord.gg/UexuTaE",
|
||||
},
|
||||
{
|
||||
label: "Twitter",
|
||||
href: "https://twitter.com/excalidraw",
|
||||
label: "𝕏",
|
||||
href: "https://x.com/excalidraw",
|
||||
},
|
||||
{
|
||||
label: "Linkedin",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
|
||||
import type { FileId } from "@excalidraw/excalidraw/element/types";
|
||||
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Arrowhead, AnyArrowhead } from "./types";
|
||||
|
||||
export const normalizeArrowhead = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
switch (arrowhead) {
|
||||
case undefined:
|
||||
case null:
|
||||
return null;
|
||||
case "dot":
|
||||
return "circle";
|
||||
case "crowfoot_one":
|
||||
return "cardinality_one";
|
||||
case "crowfoot_many":
|
||||
return "cardinality_many";
|
||||
case "crowfoot_one_or_many":
|
||||
return "cardinality_one_or_many";
|
||||
default:
|
||||
return arrowhead;
|
||||
}
|
||||
};
|
||||
|
||||
export const getArrowheadForPicker = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
const normalizedArrowhead = normalizeArrowhead(arrowhead);
|
||||
if (normalizedArrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedArrowhead;
|
||||
};
|
||||
@@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
export const getBoundsFromPoints = (
|
||||
points: ExcalidrawFreeDrawElement["points"],
|
||||
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
|
||||
points: readonly P[],
|
||||
padding: number = 0,
|
||||
): Bounds => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
@@ -695,7 +696,7 @@ export const getBoundsFromPoints = (
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
|
||||
return [minX, minY, maxX, maxY];
|
||||
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
|
||||
};
|
||||
|
||||
const getFreeDrawElementAbsoluteCoords = (
|
||||
@@ -709,6 +710,9 @@ const getFreeDrawElementAbsoluteCoords = (
|
||||
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
|
||||
};
|
||||
|
||||
const CARDINALITY_MARKER_SIZE = 20;
|
||||
const CROWFOOT_ARROWHEAD_SIZE = 15;
|
||||
|
||||
/** @returns number in pixels */
|
||||
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
switch (arrowhead) {
|
||||
@@ -717,10 +721,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
case "diamond":
|
||||
case "diamond_outline":
|
||||
return 12;
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one":
|
||||
case "crowfoot_one_or_many":
|
||||
return 20;
|
||||
case "cardinality_many":
|
||||
case "cardinality_one_or_many":
|
||||
case "cardinality_zero_or_many":
|
||||
return CROWFOOT_ARROWHEAD_SIZE;
|
||||
case "cardinality_one":
|
||||
case "cardinality_exactly_one":
|
||||
case "cardinality_zero_or_one":
|
||||
return CARDINALITY_MARKER_SIZE;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
@@ -743,7 +751,12 @@ export const getArrowheadPoints = (
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
offsetMultiplier = 0,
|
||||
) => {
|
||||
if (arrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shape.length < 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -824,29 +837,30 @@ export const getArrowheadPoints = (
|
||||
const lengthMultiplier =
|
||||
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
||||
const minSize = Math.min(size, length * lengthMultiplier);
|
||||
const xs = x2 - nx * minSize;
|
||||
const ys = y2 - ny * minSize;
|
||||
const tx = x2 - nx * minSize * offsetMultiplier;
|
||||
const ty = y2 - ny * minSize * offsetMultiplier;
|
||||
const xs = tx - nx * minSize;
|
||||
const ys = ty - ny * minSize;
|
||||
|
||||
if (
|
||||
arrowhead === "dot" ||
|
||||
arrowhead === "circle" ||
|
||||
arrowhead === "circle_outline"
|
||||
) {
|
||||
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
|
||||
return [x2, y2, diameter];
|
||||
if (arrowhead === "circle" || arrowhead === "circle_outline") {
|
||||
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
|
||||
return [tx, ty, diameter];
|
||||
}
|
||||
|
||||
const angle = getArrowheadAngle(arrowhead);
|
||||
|
||||
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
|
||||
if (
|
||||
arrowhead === "cardinality_many" ||
|
||||
arrowhead === "cardinality_one_or_many"
|
||||
) {
|
||||
// swap (xs, ys) with (x2, y2)
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(-angle as Degrees),
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
@@ -856,12 +870,12 @@ export const getArrowheadPoints = (
|
||||
// Return points
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
@@ -874,9 +888,9 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
pointFrom(tx + minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(py - ty, px - tx) as Radians,
|
||||
);
|
||||
} else {
|
||||
const [px, py] =
|
||||
@@ -885,16 +899,16 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
pointFrom(tx - minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(ty - py, tx - px) as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
return [x2, y2, x3, y3, ox, oy, x4, y4];
|
||||
return [tx, ty, x3, y3, ox, oy, x4, y4];
|
||||
}
|
||||
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
return [tx, ty, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
// TODO reuse shape.ts
|
||||
@@ -1248,6 +1262,17 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
// TODO make pointInsideBounds inclusive and remove this function once we
|
||||
// test nothing is breaking
|
||||
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] >= bounds[0] &&
|
||||
p[0] <= bounds[2] &&
|
||||
p[1] >= bounds[1] &&
|
||||
p[1] <= bounds[3];
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1262,13 +1287,21 @@ export const doBoundsIntersect = (
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
|
||||
[
|
||||
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
|
||||
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
|
||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
|
||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
||||
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
if (isLinearElement(element)) {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
|
||||
@@ -154,14 +154,11 @@ export const hitElementItself = ({
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
const hitBounds = isPointInRotatedBounds(
|
||||
point,
|
||||
bounds,
|
||||
element.angle,
|
||||
threshold,
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
@@ -192,18 +189,32 @@ export const hitElementItself = ({
|
||||
return result;
|
||||
};
|
||||
|
||||
const isPointInRotatedBounds = (
|
||||
point: GlobalPoint,
|
||||
bounds: Bounds,
|
||||
angle: Radians,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
const adjustedPoint =
|
||||
angle === 0
|
||||
? point
|
||||
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
|
||||
|
||||
return isPointWithinBounds(
|
||||
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
|
||||
adjustedPoint,
|
||||
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
x1 -= tolerance;
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
@@ -465,7 +476,12 @@ export const intersectElementWithLineSegment = (
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
return intersectLinearOrFreeDrawWithLineSegment(
|
||||
element,
|
||||
line,
|
||||
elementsMap,
|
||||
onlyFirst,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -532,11 +548,15 @@ const lineIntersections = (
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
@@ -564,7 +584,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
const hits = curveIntersectLineSegment(c, segment, {
|
||||
iterLimit: 10,
|
||||
});
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
@@ -48,7 +48,7 @@ export const distanceToElement = (
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
|
||||
|
||||
const distanceToLinearOrFreeDraElement = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
return Math.min(
|
||||
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
getElementLineSegments,
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
doBoundsIntersect,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
@@ -920,16 +921,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
export const getElementsOverlappingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
elementsOverlappingBBox({
|
||||
elements,
|
||||
bounds: frame,
|
||||
type: "overlap",
|
||||
})
|
||||
// removes elements who are overlapping, but are in a different frame,
|
||||
return elements.filter(
|
||||
(el) =>
|
||||
// exclude elements which are overlapping, but are in a different frame,
|
||||
// and thus invisible in target frame
|
||||
.filter((el) => !el.frameId || el.frameId === frame.id)
|
||||
(!el.frameId || el.frameId === frame.id) &&
|
||||
doBoundsIntersect(
|
||||
getElementBounds(el, elementsMap),
|
||||
getElementBounds(frame, elementsMap),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -99,3 +99,4 @@ export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
|
||||
@@ -476,16 +476,22 @@ export class LinearElementEditor {
|
||||
});
|
||||
}
|
||||
|
||||
invariant(
|
||||
lastClickedPoint > -1 &&
|
||||
selectedPointsIndices.includes(lastClickedPoint) &&
|
||||
element.points[lastClickedPoint],
|
||||
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
|
||||
selectedPointsIndices,
|
||||
)}) points(0..${
|
||||
element.points.length - 1
|
||||
}) lastClickedPoint(${lastClickedPoint})`,
|
||||
);
|
||||
if (
|
||||
lastClickedPoint < 0 ||
|
||||
!selectedPointsIndices.includes(lastClickedPoint) ||
|
||||
!element.points[lastClickedPoint]
|
||||
) {
|
||||
console.error(
|
||||
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
|
||||
selectedPointsIndices,
|
||||
)}) points(0..${
|
||||
element.points.length - 1
|
||||
}) lastClickedPoint(${lastClickedPoint})`,
|
||||
);
|
||||
|
||||
// Fall back to the actual last point as a last resort.
|
||||
lastClickedPoint = element.points.length - 1;
|
||||
}
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[lastClickedPoint];
|
||||
@@ -794,6 +800,7 @@ export class LinearElementEditor {
|
||||
element.points[index + 1],
|
||||
index,
|
||||
appState.zoom,
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
midpoints.push(null);
|
||||
@@ -803,6 +810,7 @@ export class LinearElementEditor {
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
@@ -890,6 +898,7 @@ export class LinearElementEditor {
|
||||
endPoint: P,
|
||||
index: number,
|
||||
zoom: Zoom,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
if (index >= 0 && index < element.points.length) {
|
||||
@@ -904,7 +913,10 @@ export class LinearElementEditor {
|
||||
|
||||
let distance = pointDistance(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
invariant(
|
||||
lines.length === 0 && curves.length > 0,
|
||||
@@ -924,6 +936,7 @@ export class LinearElementEditor {
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
index: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint {
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
@@ -936,7 +949,10 @@ export class LinearElementEditor {
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
}
|
||||
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
invariant(
|
||||
(lines.length === 0 && curves.length > 0) ||
|
||||
@@ -1851,6 +1867,7 @@ export class LinearElementEditor {
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
@@ -2400,7 +2417,7 @@ const pointDraggingUpdates = (
|
||||
? nextArrow.points[0]
|
||||
: endBindable
|
||||
? updateBoundPoint(
|
||||
element,
|
||||
nextArrow,
|
||||
"endBinding",
|
||||
nextArrow.endBinding,
|
||||
endBindable,
|
||||
@@ -2431,7 +2448,7 @@ const pointDraggingUpdates = (
|
||||
? endLocalPoint
|
||||
: startBindable
|
||||
? updateBoundPoint(
|
||||
element,
|
||||
nextArrow,
|
||||
"startBinding",
|
||||
nextArrow.startBinding,
|
||||
startBindable,
|
||||
|
||||
@@ -1,33 +1,71 @@
|
||||
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
|
||||
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
||||
import {
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
BoxSelectionMode,
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import {
|
||||
boundsContainBounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
isElementIntersectingFrame,
|
||||
} from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
const shouldIgnoreElementFromSelection = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
) => element.locked || isBoundToContainer(element);
|
||||
|
||||
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
|
||||
selectedElements: readonly T[],
|
||||
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
|
||||
) => {
|
||||
return selectedElements.filter((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
||||
* Given an array of selected elements, if there are frames and their containing elements
|
||||
@@ -47,55 +85,243 @@ export const excludeElementsInFramesFromSelection = <
|
||||
}
|
||||
});
|
||||
|
||||
return selectedElements.filter((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return excludeElementsFromFrames(selectedElements, framesInSelection);
|
||||
};
|
||||
|
||||
export const getElementsWithinSelection = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selection: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
// TODO remove (this flag is effectively unused AFAIK)
|
||||
excludeElementsInFrames: boolean = true,
|
||||
) => {
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||
boxSelectionMode: BoxSelectionMode = "contain",
|
||||
): NonDeletedExcalidrawElement[] => {
|
||||
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
|
||||
getElementAbsoluteCoords(selection, elementsMap);
|
||||
const selectionX1 = Math.min(selectionStartX, selectionEndX);
|
||||
const selectionY1 = Math.min(selectionStartY, selectionEndY);
|
||||
const selectionX2 = Math.max(selectionStartX, selectionEndX);
|
||||
const selectionY2 = Math.max(selectionStartY, selectionEndY);
|
||||
const selectionBounds = [
|
||||
selectionX1,
|
||||
selectionY1,
|
||||
selectionX2,
|
||||
selectionY2,
|
||||
] as Bounds;
|
||||
const selectionEdges = [
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
),
|
||||
];
|
||||
|
||||
let elementsInSelection = elements.filter((element) => {
|
||||
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const framesInSelection = excludeElementsInFrames
|
||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||
: null;
|
||||
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
|
||||
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
if (containingFrame) {
|
||||
const [fx1, fy1, fx2, fy2] = getElementBounds(
|
||||
containingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
elementX1 = Math.max(fx1, elementX1);
|
||||
elementY1 = Math.max(fy1, elementY1);
|
||||
elementX2 = Math.min(fx2, elementX2);
|
||||
elementY2 = Math.min(fy2, elementY2);
|
||||
for (const element of elements) {
|
||||
if (shouldIgnoreElementFromSelection(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (
|
||||
element.locked === false &&
|
||||
element.type !== "selection" &&
|
||||
!isBoundToContainer(element) &&
|
||||
selectionX1 <= elementX1 &&
|
||||
selectionY1 <= elementY1 &&
|
||||
selectionX2 >= elementX2 &&
|
||||
selectionY2 >= elementY2
|
||||
);
|
||||
});
|
||||
const strokeWidth = element.strokeWidth;
|
||||
let labelAABB: Bounds | null = null;
|
||||
let elementAABB = getElementBounds(element, elementsMap);
|
||||
|
||||
elementsInSelection = excludeElementsInFrames
|
||||
? excludeElementsInFramesFromSelection(elementsInSelection)
|
||||
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) => {
|
||||
@@ -288,3 +514,19 @@ export const getSelectionStateForElements = (
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns editing or single-selected text element, if any.
|
||||
*/
|
||||
export const getActiveTextElement = (
|
||||
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: Pick<AppState, "editingTextElement">,
|
||||
) => {
|
||||
const activeTextElement =
|
||||
appState.editingTextElement ||
|
||||
(selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
selectedElements[0]);
|
||||
|
||||
return activeTextElement || null;
|
||||
};
|
||||
|
||||
+230
-102
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -69,10 +69,10 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
@@ -296,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
|
||||
return element;
|
||||
};
|
||||
|
||||
const generateArrowheadCardinalityOne = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, lineOptions)];
|
||||
};
|
||||
|
||||
const generateArrowheadLinesToTip = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, lineOptions),
|
||||
generator.line(x4, y4, x2, y2, lineOptions),
|
||||
];
|
||||
};
|
||||
|
||||
const getArrowheadLineOptions = (
|
||||
element: ExcalidrawLinearElement,
|
||||
options: Options,
|
||||
) => {
|
||||
const lineOptions = { ...options };
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete lineOptions.strokeLineDash;
|
||||
}
|
||||
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
|
||||
|
||||
return lineOptions;
|
||||
};
|
||||
|
||||
const generateArrowheadOutlineCircle = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
strokeColor: string,
|
||||
arrowheadPoints: number[] | null,
|
||||
fill: string,
|
||||
diameterScale = 1,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
const circleOptions = {
|
||||
...options,
|
||||
fill,
|
||||
fillStyle: "solid" as const,
|
||||
stroke: strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
};
|
||||
|
||||
delete circleOptions.strokeLineDash;
|
||||
|
||||
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
|
||||
};
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
|
||||
canvasBackgroundColor: string,
|
||||
isDarkMode: boolean,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
if (arrowhead === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const generateCrowfootOne = (
|
||||
arrowheadPoints: number[] | null,
|
||||
options: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, options)];
|
||||
};
|
||||
|
||||
const strokeColor = isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
const backgroundFillColor = isDarkMode
|
||||
? applyDarkModeFilter(canvasBackgroundColor)
|
||||
: canvasBackgroundColor;
|
||||
const cardinalityOneOrManyOffset = -0.25;
|
||||
const cardinalityZeroCircleScale = 0.8;
|
||||
|
||||
switch (arrowhead) {
|
||||
case "dot":
|
||||
case "circle":
|
||||
case "circle_outline": {
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, diameter, {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
return generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
|
||||
);
|
||||
}
|
||||
case "triangle":
|
||||
case "triangle_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
const triangleOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
delete triangleOptions.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
triangleOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "diamond":
|
||||
case "diamond_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
const diamondOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
delete diamondOptions.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -400,53 +477,117 @@ const getArrowheadShapes = (
|
||||
[x4, y4],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
diamondOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_one":
|
||||
return generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_many":
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_one_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
"cardinality_one",
|
||||
cardinalityOneOrManyOffset,
|
||||
),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_exactly_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one"),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "crowfoot_one":
|
||||
return generateCrowfootOne(arrowheadPoints, options);
|
||||
case "bar":
|
||||
case "arrow":
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one_or_many":
|
||||
default: {
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
options.roughness = Math.min(1, options.roughness || 0);
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
...(arrowhead === "crowfoot_one_or_many"
|
||||
? generateCrowfootOne(
|
||||
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||
options,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const generateLinearCollisionShape = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
) => {
|
||||
elementsMap: ElementsMap,
|
||||
): {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[] => {
|
||||
const generator = new RoughGenerator();
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
@@ -455,20 +596,7 @@ export const generateLinearCollisionShape = (
|
||||
roughness: 0,
|
||||
preserveVertices: true,
|
||||
};
|
||||
const center = getCenterForBounds(
|
||||
// Need a non-rotated center point
|
||||
element.points.reduce(
|
||||
(acc, point) => {
|
||||
return [
|
||||
Math.min(element.x + point[0], acc[0]),
|
||||
Math.min(element.y + point[1], acc[1]),
|
||||
Math.max(element.x + point[0], acc[2]),
|
||||
Math.max(element.y + point[1], acc[3]),
|
||||
];
|
||||
},
|
||||
[Infinity, Infinity, -Infinity, -Infinity],
|
||||
),
|
||||
);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
switch (element.type) {
|
||||
case "line":
|
||||
|
||||
@@ -111,6 +111,67 @@ export class Store {
|
||||
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a synthetic durable history entry without changing the live scene.
|
||||
*
|
||||
* Builds StoreSnapshot → StoreChange → StoreDelta from the provided
|
||||
* logical before/after element maps and optional appState patches, then
|
||||
* emits the resulting durable increment immediately through an isolated
|
||||
* path that does not flush pending micro actions.
|
||||
*
|
||||
* appState patches are merged on top of the current observed appState
|
||||
* baseline so only the provided keys participate in the synthetic diff.
|
||||
*/
|
||||
public commitSyntheticIncrement(params: {
|
||||
logicalBefore: {
|
||||
elements: SceneElementsMap;
|
||||
appState?: Partial<ObservedAppState>;
|
||||
};
|
||||
logicalAfter: {
|
||||
elements: SceneElementsMap;
|
||||
appState?: Partial<ObservedAppState>;
|
||||
};
|
||||
}): boolean {
|
||||
const { logicalBefore, logicalAfter } = params;
|
||||
const observedAppStateBaseline = this.snapshot.appState;
|
||||
const syntheticAppStateBefore = logicalBefore.appState
|
||||
? { ...observedAppStateBaseline, ...logicalBefore.appState }
|
||||
: observedAppStateBaseline;
|
||||
const syntheticAppStateAfter = logicalAfter.appState
|
||||
? { ...observedAppStateBaseline, ...logicalAfter.appState }
|
||||
: observedAppStateBaseline;
|
||||
const didAppStateChange = Delta.isRightDifferent(
|
||||
syntheticAppStateBefore,
|
||||
syntheticAppStateAfter,
|
||||
);
|
||||
const prevSnapshot = StoreSnapshot.create(
|
||||
logicalBefore.elements,
|
||||
syntheticAppStateBefore,
|
||||
{
|
||||
didElementsChange: true,
|
||||
didAppStateChange,
|
||||
},
|
||||
);
|
||||
const nextSnapshot = StoreSnapshot.create(
|
||||
logicalAfter.elements,
|
||||
syntheticAppStateAfter,
|
||||
{
|
||||
didElementsChange: true,
|
||||
didAppStateChange,
|
||||
},
|
||||
);
|
||||
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||
const delta = StoreDelta.calculate(prevSnapshot, nextSnapshot);
|
||||
|
||||
if (delta.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.emitIsolatedDurableIncrement(change, delta);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
|
||||
*/
|
||||
@@ -206,6 +267,7 @@ export class Store {
|
||||
public clear(): void {
|
||||
this.snapshot = StoreSnapshot.empty();
|
||||
this.scheduledMacroActions = new Set();
|
||||
this.scheduledMicroActions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +307,24 @@ export class Store {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a synthetic durable increment immediately without draining the
|
||||
* regular micro-action queue.
|
||||
*/
|
||||
private emitIsolatedDurableIncrement(change: StoreChange, delta: StoreDelta) {
|
||||
const nextSnapshot = this.applyChangeToSnapshot(change);
|
||||
|
||||
if (!nextSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.emitDurableIncrement(nextSnapshot, change, delta);
|
||||
} finally {
|
||||
this.snapshot = nextSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs change calculation and emits an ephemeral increment.
|
||||
*
|
||||
@@ -491,6 +571,89 @@ export class EphemeralIncrement extends StoreIncrement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable delta marker used to recover pre-tx undo baselines
|
||||
* without rewriting history stack entries.
|
||||
*/
|
||||
export type TxUndoOverride = {
|
||||
txId: string;
|
||||
elementId: string;
|
||||
prop: string;
|
||||
expectedInsertedValue: unknown;
|
||||
preTxBaselineValue: unknown;
|
||||
consumedKey: string;
|
||||
};
|
||||
|
||||
export type StoreDeltaMarkers = {
|
||||
txUndoOverrides?: TxUndoOverride[];
|
||||
};
|
||||
|
||||
const cloneTxUndoOverride = (override: TxUndoOverride): TxUndoOverride => ({
|
||||
...override,
|
||||
});
|
||||
|
||||
const cloneStoreDeltaMarkers = (
|
||||
markers: StoreDeltaMarkers | undefined,
|
||||
): StoreDeltaMarkers | undefined =>
|
||||
markers
|
||||
? {
|
||||
...markers,
|
||||
txUndoOverrides: markers.txUndoOverrides?.map(cloneTxUndoOverride),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const pruneStoreDeltaMarkers = (delta: StoreDelta) => {
|
||||
const txUndoOverrides = delta.markers?.txUndoOverrides;
|
||||
if (!txUndoOverrides || txUndoOverrides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedEntries = delta.elements.updated as Record<
|
||||
string,
|
||||
Delta<Record<string, unknown>>
|
||||
>;
|
||||
const nextTxUndoOverrides = txUndoOverrides.filter((override) => {
|
||||
const updatedEntry = updatedEntries[override.elementId];
|
||||
return (
|
||||
!!updatedEntry &&
|
||||
Object.prototype.hasOwnProperty.call(updatedEntry.inserted, override.prop)
|
||||
);
|
||||
});
|
||||
|
||||
if (nextTxUndoOverrides.length === txUndoOverrides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextTxUndoOverrides.length === 0) {
|
||||
delta.markers = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
delta.markers = {
|
||||
...delta.markers,
|
||||
txUndoOverrides: nextTxUndoOverrides.map(cloneTxUndoOverride),
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeStoreDeltaMarkers = (
|
||||
delta: StoreDelta,
|
||||
markers: StoreDeltaMarkers,
|
||||
) => {
|
||||
const txUndoOverrides = markers.txUndoOverrides ?? [];
|
||||
if (txUndoOverrides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = delta.markers?.txUndoOverrides ?? [];
|
||||
delta.markers = {
|
||||
...delta.markers,
|
||||
txUndoOverrides: [
|
||||
...existing.map(cloneTxUndoOverride),
|
||||
...txUndoOverrides.map(cloneTxUndoOverride),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a captured delta by the Store.
|
||||
*/
|
||||
@@ -499,6 +662,7 @@ export class StoreDelta {
|
||||
public readonly id: string,
|
||||
public readonly elements: ElementsDelta,
|
||||
public readonly appState: AppStateDelta,
|
||||
public markers?: StoreDeltaMarkers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -508,12 +672,16 @@ export class StoreDelta {
|
||||
elements: ElementsDelta,
|
||||
appState: AppStateDelta,
|
||||
opts: {
|
||||
id: string;
|
||||
} = {
|
||||
id: randomId(),
|
||||
},
|
||||
id?: string;
|
||||
markers?: StoreDeltaMarkers;
|
||||
} = {},
|
||||
) {
|
||||
return new this(opts.id, elements, appState);
|
||||
return new this(
|
||||
opts.id ?? randomId(),
|
||||
elements,
|
||||
appState,
|
||||
cloneStoreDeltaMarkers(opts.markers),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -538,11 +706,12 @@ export class StoreDelta {
|
||||
* Restore a store delta instance from a DTO.
|
||||
*/
|
||||
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||
const { id, elements, appState } = storeDeltaDTO;
|
||||
const { id, elements, appState, markers } = storeDeltaDTO;
|
||||
return new this(
|
||||
id,
|
||||
ElementsDelta.restore(elements),
|
||||
AppStateDelta.restore(appState),
|
||||
cloneStoreDeltaMarkers(markers),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -553,11 +722,12 @@ export class StoreDelta {
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
markers,
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, appState);
|
||||
return new this(id, elements, appState, cloneStoreDeltaMarkers(markers));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -569,8 +739,10 @@ export class StoreDelta {
|
||||
for (const delta of deltas) {
|
||||
aggregatedDelta.elements.squash(delta.elements);
|
||||
aggregatedDelta.appState.squash(delta.appState);
|
||||
mergeStoreDeltaMarkers(aggregatedDelta, delta.markers ?? {});
|
||||
}
|
||||
|
||||
pruneStoreDeltaMarkers(aggregatedDelta);
|
||||
return aggregatedDelta;
|
||||
}
|
||||
|
||||
@@ -578,7 +750,12 @@ export class StoreDelta {
|
||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static inverse(delta: StoreDelta) {
|
||||
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
||||
const inversed = this.create(
|
||||
delta.elements.inverse(),
|
||||
delta.appState.inverse(),
|
||||
{ markers: delta.markers },
|
||||
);
|
||||
return inversed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,7 +791,7 @@ export class StoreDelta {
|
||||
nextElements: SceneElementsMap,
|
||||
modifierOptions?: "deleted" | "inserted",
|
||||
): StoreDelta {
|
||||
return this.create(
|
||||
const nextDelta = this.create(
|
||||
delta.elements.applyLatestChanges(
|
||||
prevElements,
|
||||
nextElements,
|
||||
@@ -623,8 +800,11 @@ export class StoreDelta {
|
||||
delta.appState,
|
||||
{
|
||||
id: delta.id,
|
||||
markers: delta.markers,
|
||||
},
|
||||
);
|
||||
pruneStoreDeltaMarkers(nextDelta);
|
||||
return nextDelta;
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
|
||||
@@ -347,6 +347,7 @@ export const getContainerCenter = (
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
|
||||
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElementType;
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
}): element is ExcalidrawTextContainer =>
|
||||
VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
|
||||
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
/**
|
||||
* This module approximates browser-like soft wrapping for Excalidraw text.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
|
||||
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
|
||||
* records where each visual line came from in the source text.
|
||||
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
|
||||
* that is wider than the available width.
|
||||
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
|
||||
* whitespace so the rendered text stays consistent with what users see on canvas.
|
||||
*
|
||||
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
|
||||
* that need metadata such as mapping visual lines back to `originalText`
|
||||
* for caret placement or future editor features.
|
||||
*/
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
let cachedLineBreakRegex: RegExp | undefined;
|
||||
let cachedEmojiRegex: RegExp | undefined;
|
||||
@@ -358,6 +374,10 @@ const Break = {
|
||||
|
||||
/**
|
||||
* Breaks the line into the tokens based on the found line break opporutnities.
|
||||
*
|
||||
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
|
||||
* their composed variants for wrapping. Any code that needs exact source offsets should
|
||||
* keep in mind that this assumes the input text is already NFC-normalized.
|
||||
*/
|
||||
export const parseTokens = (line: string) => {
|
||||
const breakLineRegex = getLineBreakRegex();
|
||||
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
|
||||
|
||||
/**
|
||||
* Wraps the original text into the lines based on the given width.
|
||||
*
|
||||
* This is a convenience adapter over `getWrappedTextLines()` for call sites
|
||||
* that only need the rendered wrapped string and not the source offsets.
|
||||
*/
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
return getWrappedTextLines(text, font, maxWidth)
|
||||
.map((line) => line.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* A single rendered visual line produced from the original text.
|
||||
*
|
||||
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
|
||||
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
|
||||
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
|
||||
*/
|
||||
export type WrappedTextLine = {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits only on existing hard line breaks and preserves original offsets.
|
||||
*/
|
||||
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
|
||||
let offset = 0;
|
||||
|
||||
return text.split("\n").map((line) => {
|
||||
const start = offset;
|
||||
const end = start + line.length;
|
||||
|
||||
offset = end + 1;
|
||||
|
||||
return {
|
||||
text: line,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the rendered visual lines together with their source offsets.
|
||||
*
|
||||
* This is the source-of-truth wrapping pipeline for callers that need more than the
|
||||
* final wrapped string, for example caret placement or future editor/rich-text mapping.
|
||||
*/
|
||||
export const getWrappedTextLines = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): WrappedTextLine[] => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
return getHardLineBreaks(text);
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const lines: WrappedTextLine[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
for (const originalLine of text.split("\n")) {
|
||||
const originalLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
continue;
|
||||
if (originalLineWidth <= maxWidth) {
|
||||
lines.push({
|
||||
text: originalLine,
|
||||
start: offset,
|
||||
end: offset + originalLine.length,
|
||||
});
|
||||
} else {
|
||||
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
|
||||
}
|
||||
|
||||
const wrappedLine = wrapLine(originalLine, font, maxWidth);
|
||||
lines.push(...wrappedLine);
|
||||
offset += originalLine.length + 1;
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original line into the lines based on the given width.
|
||||
* Wraps a single hard line into one or more visual lines.
|
||||
*
|
||||
* The line-local offsets are tracked in original-text code units so
|
||||
* we can map the visual line back to the source.
|
||||
*/
|
||||
const wrapLine = (
|
||||
line: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string[] => {
|
||||
const lines: Array<string> = [];
|
||||
lineStart: number,
|
||||
): WrappedTextLine[] => {
|
||||
const lines: WrappedTextLine[] = [];
|
||||
const tokens = parseTokens(line);
|
||||
const tokenIterator = tokens[Symbol.iterator]();
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineStart = lineStart;
|
||||
let currentLineEnd = lineStart;
|
||||
let currentLineWidth = 0;
|
||||
// Tracks the next token's code-unit position in the original source string.
|
||||
let tokenOffset = lineStart;
|
||||
let tokenIndex = 0;
|
||||
|
||||
let iterator = tokenIterator.next();
|
||||
|
||||
while (!iterator.done) {
|
||||
const token = iterator.value;
|
||||
while (tokenIndex < tokens.length) {
|
||||
const token = tokens[tokenIndex];
|
||||
const tokenStart = tokenOffset;
|
||||
const tokenEnd = tokenStart + token.length;
|
||||
const testLine = currentLine + token;
|
||||
|
||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
@@ -429,37 +513,59 @@ const wrapLine = (
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
if (!currentLine) {
|
||||
currentLineStart = tokenStart;
|
||||
}
|
||||
currentLine = testLine;
|
||||
currentLineEnd = tokenEnd;
|
||||
currentLineWidth = testLineWidth;
|
||||
iterator = tokenIterator.next();
|
||||
tokenOffset = tokenEnd;
|
||||
tokenIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
|
||||
if (!currentLine) {
|
||||
const wrappedWord = wrapWord(token, font, maxWidth);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
|
||||
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
|
||||
text: "",
|
||||
start: tokenStart,
|
||||
end: tokenStart,
|
||||
};
|
||||
const precedingLines = wrappedWord.slice(0, -1);
|
||||
|
||||
lines.push(...precedingLines);
|
||||
|
||||
// trailing line of the wrapped word might still be joined with next token/s
|
||||
currentLine = trailingLine;
|
||||
currentLineWidth = getLineWidth(trailingLine, font);
|
||||
iterator = tokenIterator.next();
|
||||
currentLine = trailingLine.text;
|
||||
currentLineStart = trailingLine.start;
|
||||
currentLineEnd = trailingLine.end;
|
||||
currentLineWidth = getLineWidth(trailingLine.text, font);
|
||||
tokenOffset = tokenEnd;
|
||||
tokenIndex++;
|
||||
} else {
|
||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||
lines.push(currentLine.trimEnd());
|
||||
lines.push(
|
||||
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
|
||||
);
|
||||
|
||||
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
|
||||
currentLine = "";
|
||||
currentLineStart = tokenStart;
|
||||
currentLineEnd = tokenStart;
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// iterator done, push the trailing line if exists
|
||||
if (currentLine) {
|
||||
const trailingLine = trimLine(currentLine, font, maxWidth);
|
||||
const trailingLine = trimLine(
|
||||
currentLine,
|
||||
currentLineStart,
|
||||
currentLineEnd,
|
||||
font,
|
||||
maxWidth,
|
||||
);
|
||||
lines.push(trailingLine);
|
||||
}
|
||||
|
||||
@@ -467,59 +573,100 @@ const wrapLine = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the word into the lines based on the given width.
|
||||
* Wraps a single word that could not be placed on an empty line as-is.
|
||||
*/
|
||||
const wrapWord = (
|
||||
word: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): Array<string> => {
|
||||
wordStart: number,
|
||||
): WrappedTextLine[] => {
|
||||
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
||||
if (getEmojiRegex().test(word)) {
|
||||
return [word];
|
||||
return [
|
||||
{
|
||||
text: word,
|
||||
start: wordStart,
|
||||
end: wordStart + word.length,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
satisfiesWordInvariant(word);
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const lines: WrappedTextLine[] = [];
|
||||
const chars = Array.from(word);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineStart = wordStart;
|
||||
let currentLineEnd = wordStart;
|
||||
let currentLineWidth = 0;
|
||||
let offset = wordStart;
|
||||
|
||||
for (const char of chars) {
|
||||
const charStart = offset;
|
||||
const charEnd = charStart + char.length;
|
||||
const _charWidth = charWidth.calculate(char, font);
|
||||
const testLineWidth = currentLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth <= maxWidth) {
|
||||
if (!currentLine) {
|
||||
currentLineStart = charStart;
|
||||
}
|
||||
currentLine = currentLine + char;
|
||||
currentLineEnd = charEnd;
|
||||
currentLineWidth = testLineWidth;
|
||||
offset = charEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
lines.push({
|
||||
text: currentLine,
|
||||
start: currentLineStart,
|
||||
end: currentLineEnd,
|
||||
});
|
||||
}
|
||||
|
||||
currentLine = char;
|
||||
currentLineStart = charStart;
|
||||
currentLineEnd = charEnd;
|
||||
currentLineWidth = _charWidth;
|
||||
offset = charEnd;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
lines.push({
|
||||
text: currentLine,
|
||||
start: currentLineStart,
|
||||
end: currentLineEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||
* Trims trailing whitespace that is exceeding the `maxWidth`.
|
||||
*
|
||||
* Used for the trailing visual line of a hard line, where some trailing
|
||||
* whitespace may still be visible if it fits into the available width.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const trimLine = (
|
||||
line: string,
|
||||
start: number,
|
||||
end: number,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): WrappedTextLine => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
return {
|
||||
text: line,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
// defensively default to `trimeEnd` in case the regex does not match
|
||||
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
trimmedLineWidth = testLineWidth;
|
||||
}
|
||||
|
||||
return trimmedLine;
|
||||
return {
|
||||
text: trimmedLine,
|
||||
start,
|
||||
end: end - (line.length - trimmedLine.length),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Used for internal soft-wrap boundaries, where trailing whitespace should not
|
||||
* survive into the rendered line even though it still exists in the original
|
||||
* text.
|
||||
*/
|
||||
const trimLineEndAtSoftBreak = (
|
||||
line: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): WrappedTextLine => {
|
||||
const trimmedLine = line.trimEnd();
|
||||
|
||||
return {
|
||||
text: trimmedLine,
|
||||
start,
|
||||
end: end - (line.length - trimmedLine.length),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type CardinalityArrowhead =
|
||||
| "cardinality_one"
|
||||
| "cardinality_many"
|
||||
| "cardinality_one_or_many"
|
||||
| "cardinality_exactly_one"
|
||||
| "cardinality_zero_or_one"
|
||||
| "cardinality_zero_or_many";
|
||||
|
||||
export type ArrowheadLegacy =
|
||||
| "dot"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
| "dot" // legacy. Do not use for new elements.
|
||||
| "circle"
|
||||
| "circle_outline"
|
||||
| "triangle"
|
||||
| "triangle_outline"
|
||||
| "diamond"
|
||||
| "diamond_outline"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
| CardinalityArrowhead;
|
||||
|
||||
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
|
||||
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
@@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const ops = generateLinearCollisionShape(element, elementsMap);
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { arrayToMap, reseed } from "@excalidraw/common";
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
@@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision";
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import type App from "@excalidraw/excalidraw/components/App";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
CaptureUpdateAction,
|
||||
Store,
|
||||
StoreSnapshot,
|
||||
newElementWith,
|
||||
} from "../src";
|
||||
|
||||
const getScheduledMicroActionCount = (store: Store) =>
|
||||
(
|
||||
store as unknown as {
|
||||
scheduledMicroActions: Array<() => void>;
|
||||
}
|
||||
).scheduledMicroActions.length;
|
||||
|
||||
const flushMicroActions = (store: Store) => {
|
||||
(
|
||||
store as unknown as {
|
||||
flushMicroActions: () => void;
|
||||
}
|
||||
).flushMicroActions();
|
||||
};
|
||||
|
||||
const toSceneElementsMap = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): SceneElementsMap => arrayToMap(elements) as SceneElementsMap;
|
||||
|
||||
const createStoreHarness = (elements: SceneElementsMap) => {
|
||||
const appState: ObservedAppState = StoreSnapshot.empty().appState;
|
||||
const app = {
|
||||
scene: {
|
||||
getElementsMapIncludingDeleted: () => elements,
|
||||
},
|
||||
state: appState,
|
||||
} as unknown as App;
|
||||
|
||||
const store = new Store(app);
|
||||
store.snapshot = StoreSnapshot.create(elements, appState);
|
||||
|
||||
return { store, appState };
|
||||
};
|
||||
|
||||
describe("Store synthetic increment isolation", () => {
|
||||
it("keeps pending immediate micro actions queued across synthetic commit", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "store-isolated-immediate",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const baselineElements = toSceneElementsMap([element]);
|
||||
const { store } = createStoreHarness(baselineElements);
|
||||
|
||||
const incrementTypes: Array<"durable" | "ephemeral"> = [];
|
||||
const detach = store.onStoreIncrementEmitter.on((increment) => {
|
||||
incrementTypes.push(increment.type);
|
||||
});
|
||||
|
||||
store.scheduleMicroAction({
|
||||
action: CaptureUpdateAction.IMMEDIATELY,
|
||||
elements: [newElementWith(element, { y: 240 })],
|
||||
appState: undefined,
|
||||
});
|
||||
|
||||
expect(getScheduledMicroActionCount(store)).toBe(1);
|
||||
|
||||
const committed = store.commitSyntheticIncrement({
|
||||
logicalBefore: {
|
||||
elements: baselineElements,
|
||||
},
|
||||
logicalAfter: {
|
||||
elements: toSceneElementsMap([newElementWith(element, { x: 120 })]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(committed).toBe(true);
|
||||
expect(incrementTypes).toEqual(["durable"]);
|
||||
expect(getScheduledMicroActionCount(store)).toBe(1);
|
||||
|
||||
flushMicroActions(store);
|
||||
|
||||
expect(incrementTypes).toEqual(["durable", "durable"]);
|
||||
expect(getScheduledMicroActionCount(store)).toBe(0);
|
||||
detach();
|
||||
});
|
||||
|
||||
it("keeps pending eventually micro actions queued across synthetic commit", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "store-isolated-eventually",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const baselineElements = toSceneElementsMap([element]);
|
||||
const { store } = createStoreHarness(baselineElements);
|
||||
|
||||
const incrementTypes: Array<"durable" | "ephemeral"> = [];
|
||||
const detach = store.onStoreIncrementEmitter.on((increment) => {
|
||||
incrementTypes.push(increment.type);
|
||||
});
|
||||
|
||||
store.scheduleMicroAction({
|
||||
action: CaptureUpdateAction.EVENTUALLY,
|
||||
elements: [newElementWith(element, { y: 240 })],
|
||||
appState: undefined,
|
||||
});
|
||||
|
||||
expect(getScheduledMicroActionCount(store)).toBe(1);
|
||||
|
||||
const committed = store.commitSyntheticIncrement({
|
||||
logicalBefore: {
|
||||
elements: baselineElements,
|
||||
},
|
||||
logicalAfter: {
|
||||
elements: toSceneElementsMap([newElementWith(element, { x: 120 })]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(committed).toBe(true);
|
||||
expect(incrementTypes).toEqual(["durable"]);
|
||||
expect(getScheduledMicroActionCount(store)).toBe(1);
|
||||
|
||||
flushMicroActions(store);
|
||||
|
||||
expect(incrementTypes).toEqual(["durable", "ephemeral"]);
|
||||
expect(getScheduledMicroActionCount(store)).toBe(0);
|
||||
detach();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { wrapText, parseTokens } from "../src/textWrapping";
|
||||
import {
|
||||
getWrappedTextLines,
|
||||
parseTokens,
|
||||
wrapText,
|
||||
} from "../src/textWrapping";
|
||||
|
||||
import type { FontString } from "../src/types";
|
||||
|
||||
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
|
||||
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
||||
});
|
||||
|
||||
it("should retain original text offsets for wrapped lines", () => {
|
||||
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
|
||||
{
|
||||
text: "Hello",
|
||||
start: 0,
|
||||
end: 5,
|
||||
},
|
||||
{
|
||||
text: "World!",
|
||||
start: 6,
|
||||
end: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
|
||||
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
|
||||
{
|
||||
text: " Hello",
|
||||
start: 0,
|
||||
end: 7,
|
||||
},
|
||||
{
|
||||
text: "World",
|
||||
start: 9,
|
||||
end: 14,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should retain offsets when wrapping a single long token", () => {
|
||||
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
|
||||
{
|
||||
text: "Excal",
|
||||
start: 0,
|
||||
end: 5,
|
||||
},
|
||||
{
|
||||
text: "idraw",
|
||||
start: 5,
|
||||
end: 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve empty hard lines in metadata", () => {
|
||||
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
|
||||
{
|
||||
text: "A",
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
text: "",
|
||||
start: 2,
|
||||
end: 2,
|
||||
},
|
||||
{
|
||||
text: "B",
|
||||
start: 3,
|
||||
end: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("When text is CJK", () => {
|
||||
it("should break each CJK character when width is very small", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
|
||||
@@ -18,22 +18,13 @@ Please add the latest change on the top under the correct section.
|
||||
### Breaking changes
|
||||
|
||||
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
||||
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
|
||||
|
||||
### Features
|
||||
|
||||
- Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded.
|
||||
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onMount={({ excalidrawAPI, container }) => {
|
||||
console.log(container);
|
||||
excalidrawAPI.scrollToContent();
|
||||
}}
|
||||
onInitialize={(api) => {
|
||||
api.refresh();
|
||||
}}
|
||||
/>
|
||||
```
|
||||
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
|
||||
|
||||
- Same events are also accessible imperatively through `api.onEvent(...)`.
|
||||
|
||||
@@ -41,7 +32,6 @@ Please add the latest change on the top under the correct section.
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => {
|
||||
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
|
||||
excalidrawAPI.scrollToContent();
|
||||
console.log(container);
|
||||
});
|
||||
|
||||
@@ -54,7 +44,9 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
|
||||
|
||||
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`.
|
||||
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
|
||||
|
||||
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
|
||||
|
||||
```tsx
|
||||
<ExcalidrawAPIProvider>
|
||||
@@ -63,6 +55,9 @@ Please add the latest change on the top under the correct section.
|
||||
</ExcalidrawAPIProvider>;
|
||||
|
||||
function Logger() {
|
||||
// initially null before the ExcalidrawAPIProvider initializes ater
|
||||
// <Excalidraw/> renders
|
||||
// When <Excalidraw/> unmounts, is reset back to null
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
|
||||
|
||||
+111
-14
@@ -1,10 +1,10 @@
|
||||
# Excalidraw
|
||||
|
||||
**Excalidraw** is exported as a component to be directly embedded in your project.
|
||||
**Excalidraw** is exported as a React component that you can embed directly in your app.
|
||||
|
||||
## Installation
|
||||
|
||||
Use `npm` or `yarn` to install the package.
|
||||
Install the package together with its React peer dependencies.
|
||||
|
||||
```bash
|
||||
npm install react react-dom @excalidraw/excalidraw
|
||||
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
|
||||
yarn add react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
|
||||
#### Self-hosting fonts
|
||||
## Quick start
|
||||
|
||||
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
The minimum working setup has two easy-to-miss requirements:
|
||||
|
||||
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
|
||||
1. Import the package CSS:
|
||||
|
||||
```js
|
||||
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
|
||||
```ts
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```
|
||||
|
||||
### Dimensions of Excalidraw
|
||||
2. Render Excalidraw inside a container with a non-zero height.
|
||||
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
```tsx
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
|
||||
|
||||
## Next.js / SSR frameworks
|
||||
|
||||
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
|
||||
|
||||
```tsx
|
||||
// app/components/ExcalidrawClient.tsx
|
||||
"use client";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function ExcalidrawClient() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ExcalidrawClient = dynamic(
|
||||
() => import("./components/ExcalidrawClient"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <ExcalidrawClient />;
|
||||
}
|
||||
```
|
||||
|
||||
See the local examples for complete setups:
|
||||
|
||||
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
|
||||
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
|
||||
|
||||
## LLM / agent tips
|
||||
|
||||
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
|
||||
|
||||
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
|
||||
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
|
||||
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
|
||||
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
|
||||
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
|
||||
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
|
||||
|
||||
## Migrating to `@excalidraw/excalidraw@0.18.x`
|
||||
|
||||
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
|
||||
|
||||
| Old path | New path |
|
||||
| --- | --- |
|
||||
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
|
||||
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
|
||||
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
|
||||
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
|
||||
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
|
||||
|
||||
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
|
||||
|
||||
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
```
|
||||
|
||||
## Self-hosting fonts
|
||||
|
||||
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
|
||||
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
</script>
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
|
||||
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
|
||||
|
||||
## Integration
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
|
||||
## API
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
|
||||
## Contributing
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
getElementsInGroup,
|
||||
isSomeElementSelected,
|
||||
makeNextSelectedElementIds,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import type { GroupId } from "@excalidraw/element/types";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const getNextActiveTool = (
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
return updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
}
|
||||
|
||||
return updateActiveTool(appState, {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
});
|
||||
};
|
||||
|
||||
const getParentEditingGroupId = (
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
): GroupId | null => {
|
||||
if (!appState.editingGroupId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds,
|
||||
elements: nonDeletedElements,
|
||||
});
|
||||
const candidateElements = selectedElements.length
|
||||
? selectedElements
|
||||
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
|
||||
|
||||
for (const element of candidateElements) {
|
||||
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
|
||||
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
|
||||
return element.groupIds[editingGroupIndex + 1] as GroupId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const actionDeselect = register({
|
||||
name: "deselect",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, _, app) => {
|
||||
const activeTool = getNextActiveTool(appState, app);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
const selectedElementIds =
|
||||
Object.keys(appState.selectedElementIds).length > 0
|
||||
? appState.selectedElementIds
|
||||
: getElementsInGroup(
|
||||
nonDeletedElements,
|
||||
appState.editingGroupId,
|
||||
).reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, true>);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: getParentEditingGroupId(
|
||||
appState,
|
||||
app,
|
||||
selectedElementIds,
|
||||
),
|
||||
selectedElementIds,
|
||||
},
|
||||
nonDeletedElements,
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
selectedLinearElement: null,
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds({}, appState),
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElement: null,
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState, _, app) => {
|
||||
if (event.key !== KEYS.ESCAPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isWritableElement(event.target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!appState.newElement &&
|
||||
appState.multiElement === null &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
(appState.activeEmbeddable !== null ||
|
||||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
|
||||
!!appState.editingGroupId ||
|
||||
!!appState.selectedLinearElement ||
|
||||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
|
||||
appState: {
|
||||
openDialog: null,
|
||||
fileHandle: savedFileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
toast: { message: t("toast.fileSaved"), duration: 3000 },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -348,9 +348,7 @@ export const actionFinalize = register<FormData>({
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.selectedLinearElement?.isEditing ||
|
||||
(!appState.newElement && appState.multiElement === null))) ||
|
||||
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getArrowheadForPicker } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
@@ -124,9 +125,12 @@ import {
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
ArrowheadCrowfootIcon,
|
||||
ArrowheadCrowfootOneIcon,
|
||||
ArrowheadCrowfootOneOrManyIcon,
|
||||
ArrowheadCardinalityExactlyOneIcon,
|
||||
ArrowheadCardinalityManyIcon,
|
||||
ArrowheadCardinalityOneIcon,
|
||||
ArrowheadCardinalityOneOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrOneIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { Fonts } from "../fonts";
|
||||
@@ -187,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
|
||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||
): T {
|
||||
const editingTextElement = app.state.editingTextElement;
|
||||
@@ -205,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
|
||||
if (hasSelection) {
|
||||
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||
const targetElements =
|
||||
isRelevantElement === true
|
||||
elementPredicate === true
|
||||
? selectedElements
|
||||
: selectedElements.filter((el) => isRelevantElement(el));
|
||||
: selectedElements.filter((el) => elementPredicate(el));
|
||||
|
||||
ret =
|
||||
reduceToCommonValue(targetElements, getAttribute) ??
|
||||
@@ -726,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ app, updateData }) => (
|
||||
<Range updateData={updateData} app={app} testId="opacity" />
|
||||
),
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const opacity = getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.opacity,
|
||||
true,
|
||||
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
|
||||
);
|
||||
|
||||
return (
|
||||
<Range
|
||||
label={t("labels.opacity")}
|
||||
value={opacity ?? appState.currentItemOpacity}
|
||||
hasCommonValue={opacity !== null}
|
||||
onChange={updateData}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
testId="opacity"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
|
||||
@@ -1550,80 +1573,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
});
|
||||
|
||||
const getArrowheadOptions = (flip: boolean) => {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one",
|
||||
text: t("labels.arrowhead_crowfoot_one"),
|
||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_many",
|
||||
text: t("labels.arrowhead_crowfoot_many"),
|
||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one_or_many",
|
||||
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
] as const;
|
||||
return {
|
||||
visibleSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hiddenSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("labels.cardinality"),
|
||||
options: [
|
||||
{
|
||||
value: "cardinality_one",
|
||||
text: t("labels.arrowhead_cardinality_one"),
|
||||
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "cardinality_many",
|
||||
text: t("labels.arrowhead_cardinality_many"),
|
||||
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "cardinality_one_or_many",
|
||||
text: t("labels.arrowhead_cardinality_one_or_many"),
|
||||
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
{
|
||||
value: "cardinality_exactly_one",
|
||||
text: t("labels.arrowhead_cardinality_exactly_one"),
|
||||
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_one",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_one"),
|
||||
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_many",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_many"),
|
||||
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const actionChangeArrowhead = register<{
|
||||
@@ -1667,45 +1727,52 @@ export const actionChangeArrowhead = register<{
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
const startArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
const endArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowheads")}</legend>
|
||||
<div className="iconSelectList buttonList">
|
||||
<IconPicker
|
||||
visibleSections={startArrowheadOptions.visibleSections}
|
||||
hiddenSections={startArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_start"
|
||||
options={getArrowheadOptions(!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.startArrowhead
|
||||
? getArrowheadForPicker(element.startArrowhead)
|
||||
: appState.currentItemStartArrowhead,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
<IconPicker
|
||||
visibleSections={endArrowheadOptions.visibleSections}
|
||||
hiddenSections={endArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_end"
|
||||
group="arrowheads"
|
||||
options={getArrowheadOptions(!!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.endArrowhead
|
||||
? getArrowheadForPicker(element.endArrowhead)
|
||||
: appState.currentItemEndArrowhead,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const actionTextAutoResize = register({
|
||||
name: "autoResize",
|
||||
label: "labels.autoResize",
|
||||
icon: null,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||
predicate: (elements, appState, _: unknown) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 1 &&
|
||||
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: (elements, appState, targetElement) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const targetTextElement =
|
||||
isExcalidrawElement(targetElement) && isTextElement(targetElement)
|
||||
? targetElement
|
||||
: (selectedElements[0] as ExcalidrawElement | undefined);
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||
if (element.id === targetTextElement?.id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
export { actionDeselect } from "./actionDeselect";
|
||||
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
|
||||
@@ -114,6 +114,7 @@ export type ActionName =
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "deselect"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme"
|
||||
|
||||
@@ -128,6 +128,7 @@ export const getDefaultAppState = (): Omit<
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
bindMode: "orbit",
|
||||
boxSelectionMode: "contain",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -193,6 +194,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridModeEnabled: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: true, export: false, server: false },
|
||||
boxSelectionMode: { browser: true, export: false, server: false },
|
||||
bindingPreference: { browser: true, export: false, server: false },
|
||||
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ import {
|
||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
|
||||
|
||||
export interface FontDescriptor {
|
||||
value: number;
|
||||
@@ -86,6 +87,15 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
const getFontFamilyLabel = (
|
||||
fontFamily: FontFamilyValues,
|
||||
fontFaces: ExcalidrawFontFace[],
|
||||
) =>
|
||||
// prefer our config as the browser resolved names may be wrapped in quotes and such
|
||||
Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ??
|
||||
fontFaces[0]?.fontFace?.family ??
|
||||
"Unknown";
|
||||
|
||||
export const FontPickerList = React.memo(
|
||||
({
|
||||
selectedFontFamily,
|
||||
@@ -114,7 +124,7 @@ export const FontPickerList = React.memo(
|
||||
const fontDescriptor = {
|
||||
value: familyId,
|
||||
icon: getFontFamilyIcon(familyId),
|
||||
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
|
||||
text: getFontFamilyLabel(familyId, fontFaces),
|
||||
};
|
||||
|
||||
if (metadata.deprecated) {
|
||||
|
||||
@@ -6,14 +6,20 @@
|
||||
padding: 0.5rem;
|
||||
background: var(--popup-bg-color);
|
||||
border: 0 solid color.adjust(#fff, $alpha: -0.75);
|
||||
box-shadow: var(--shadow-island);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-sections,
|
||||
.picker-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.picker-container button,
|
||||
.picker button {
|
||||
position: relative;
|
||||
@@ -62,7 +68,13 @@
|
||||
|
||||
.picker-collapsible {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
padding: 0;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.picker-section-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.picker-keybinding {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Popover } from "radix-ui";
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
|
||||
import { isArrowKey, KEYS } from "@excalidraw/common";
|
||||
|
||||
@@ -8,13 +8,15 @@ import { atom, useAtom } from "../editor-jotai";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { useEditorInterface, useExcalidrawContainer } from "./App";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const moreOptionsAtom = atom(false);
|
||||
const PICKER_COLUMNS = 4;
|
||||
const DEFAULT_SECTION_NAME = "default";
|
||||
|
||||
type Option<T> = {
|
||||
value: T;
|
||||
@@ -23,28 +25,73 @@ type Option<T> = {
|
||||
keyBinding: string | null;
|
||||
};
|
||||
|
||||
type PickerSection<T> = {
|
||||
name: string;
|
||||
options: readonly Option<T>[];
|
||||
};
|
||||
|
||||
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
|
||||
sections.flatMap((section) => section.options);
|
||||
|
||||
const findOption = <T,>(
|
||||
sections: readonly PickerSection<T>[],
|
||||
predicate: (option: Option<T>) => boolean,
|
||||
) => {
|
||||
for (const section of sections) {
|
||||
const option = section.options.find(predicate);
|
||||
if (option) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasOption = <T,>(
|
||||
sections: readonly PickerSection<T>[],
|
||||
predicate: (option: Option<T>) => boolean,
|
||||
) => sections.some((section) => section.options.some(predicate));
|
||||
|
||||
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
|
||||
sections.flatMap((section) =>
|
||||
Array.from(
|
||||
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
|
||||
(_, index) =>
|
||||
section.options.slice(
|
||||
index * PICKER_COLUMNS,
|
||||
index * PICKER_COLUMNS + PICKER_COLUMNS,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
function Picker<T>({
|
||||
options,
|
||||
visibleSections,
|
||||
hiddenSections = [],
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
onClose,
|
||||
numberOfOptionsToAlwaysShow = options.length,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly Option<T>[];
|
||||
visibleSections: readonly PickerSection<T>[];
|
||||
hiddenSections?: readonly PickerSection<T>[];
|
||||
onChange: (value: T) => void;
|
||||
onClose: () => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
}) {
|
||||
const editorInterface = useEditorInterface();
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||
const allSections = [...visibleSections, ...hiddenSections];
|
||||
const allOptions = flattenOptions(allSections);
|
||||
const navigationRows = getNavigationRows([
|
||||
...visibleSections,
|
||||
...(showMoreOptions ? hiddenSections : []),
|
||||
]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = options.find(
|
||||
const pressedOption = allOptions.find(
|
||||
(option) => option.keyBinding === event.key.toLowerCase(),
|
||||
)!;
|
||||
);
|
||||
|
||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||
// Keybinding navigation
|
||||
@@ -52,17 +99,17 @@ function Picker<T>({
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.TAB) {
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
const nextIndex = event.shiftKey
|
||||
? (options.length + index - 1) % options.length
|
||||
: (index + 1) % options.length;
|
||||
onChange(options[nextIndex].value);
|
||||
? (allOptions.length + index - 1) % allOptions.length
|
||||
: (index + 1) % allOptions.length;
|
||||
onChange(allOptions[nextIndex].value);
|
||||
} else if (isArrowKey(event.key)) {
|
||||
// Arrow navigation
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = options.findIndex((option) => option.value === value);
|
||||
const index = allOptions.findIndex((option) => option.value === value);
|
||||
if (index !== -1) {
|
||||
const length = options.length;
|
||||
const length = allOptions.length;
|
||||
let nextIndex = index;
|
||||
|
||||
switch (event.key) {
|
||||
@@ -76,18 +123,60 @@ function Picker<T>({
|
||||
break;
|
||||
// Go the next row
|
||||
case KEYS.ARROW_DOWN: {
|
||||
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||
const currentRowIndex = navigationRows.findIndex((row) =>
|
||||
row.some((option) => option.value === value),
|
||||
);
|
||||
const currentRow = navigationRows[currentRowIndex];
|
||||
|
||||
if (currentRowIndex !== -1 && currentRow) {
|
||||
const column = currentRow.findIndex(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
const nextRow =
|
||||
navigationRows[(currentRowIndex + 1) % navigationRows.length];
|
||||
const nextOption =
|
||||
nextRow[Math.min(column, nextRow.length - 1)] ??
|
||||
allOptions[index];
|
||||
|
||||
onChange(nextOption.value);
|
||||
event.preventDefault();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Go the previous row
|
||||
case KEYS.ARROW_UP: {
|
||||
nextIndex =
|
||||
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||
const currentRowIndex = navigationRows.findIndex((row) =>
|
||||
row.some((option) => option.value === value),
|
||||
);
|
||||
const currentRow = navigationRows[currentRowIndex];
|
||||
|
||||
if (currentRowIndex !== -1 && currentRow) {
|
||||
const column = currentRow.findIndex(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
const previousRow =
|
||||
navigationRows[
|
||||
(navigationRows.length + currentRowIndex - 1) %
|
||||
navigationRows.length
|
||||
];
|
||||
const previousOption =
|
||||
previousRow[Math.min(column, previousRow.length - 1)] ??
|
||||
allOptions[index];
|
||||
|
||||
onChange(previousOption.value);
|
||||
event.preventDefault();
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(options[nextIndex].value);
|
||||
onChange(allOptions[nextIndex].value);
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
@@ -99,38 +188,29 @@ function Picker<T>({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||
|
||||
const alwaysVisibleOptions = React.useMemo(
|
||||
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||
[options, numberOfOptionsToAlwaysShow],
|
||||
);
|
||||
const moreOptions = React.useMemo(
|
||||
() => options.slice(numberOfOptionsToAlwaysShow),
|
||||
[options, numberOfOptionsToAlwaysShow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
|
||||
if (hasOption(hiddenSections, (option) => option.value === value)) {
|
||||
setShowMoreOptions(true);
|
||||
}
|
||||
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
|
||||
}, [value, hiddenSections, setShowMoreOptions]);
|
||||
|
||||
const renderOptions = (options: Option<T>[]) => {
|
||||
const renderOptions = (options: readonly Option<T>[]) => {
|
||||
return (
|
||||
<div className="picker-content">
|
||||
{options.map((option, i) => (
|
||||
{options.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("picker-option", {
|
||||
active: value === option.value,
|
||||
})}
|
||||
onClick={(event) => {
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
}}
|
||||
title={`${option.text} ${
|
||||
option.keyBinding && `— ${option.keyBinding.toUpperCase()}`
|
||||
}`}
|
||||
title={
|
||||
option.keyBinding
|
||||
? `${option.text} — ${option.keyBinding.toUpperCase()}`
|
||||
: option.text
|
||||
}
|
||||
aria-label={option.text || "none"}
|
||||
aria-keyshortcuts={option.keyBinding || undefined}
|
||||
key={option.text}
|
||||
@@ -153,26 +233,38 @@ function Picker<T>({
|
||||
);
|
||||
};
|
||||
|
||||
const isMobile = editorInterface.formFactor === "phone";
|
||||
const renderSections = (sections: readonly PickerSection<T>[]) =>
|
||||
sections.map((section, index) =>
|
||||
section.name === DEFAULT_SECTION_NAME ? (
|
||||
<React.Fragment key={`${section.name}-${index}`}>
|
||||
{renderOptions(section.options)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div className="picker-section" key={`${section.name}-${index}`}>
|
||||
<div className="picker-section-label">{section.name}</div>
|
||||
{renderOptions(section.options)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Content
|
||||
side={isMobile ? "right" : "bottom"}
|
||||
className="picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
side={"bottom"}
|
||||
align="start"
|
||||
sideOffset={isMobile ? 8 : 12}
|
||||
sideOffset={12}
|
||||
alignOffset={12}
|
||||
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||
onKeyDown={handleKeyDown}
|
||||
collisionBoundary={container ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={`picker`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
>
|
||||
{renderOptions(alwaysVisibleOptions)}
|
||||
<div className="picker-sections">
|
||||
{renderSections(visibleSections)}
|
||||
|
||||
{moreOptions.length > 0 && (
|
||||
{hiddenSections.length > 0 && (
|
||||
<Collapsible
|
||||
label={t("labels.more_options")}
|
||||
open={showMoreOptions}
|
||||
@@ -181,7 +273,9 @@ function Picker<T>({
|
||||
}}
|
||||
className="picker-collapsible"
|
||||
>
|
||||
{renderOptions(moreOptions)}
|
||||
<div className="picker-sections">
|
||||
{renderSections(hiddenSections)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,49 +286,45 @@ function Picker<T>({
|
||||
export function IconPicker<T>({
|
||||
value,
|
||||
label,
|
||||
options,
|
||||
visibleSections,
|
||||
hiddenSections,
|
||||
onChange,
|
||||
group = "",
|
||||
numberOfOptionsToAlwaysShow,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
}[];
|
||||
visibleSections: readonly PickerSection<T>[];
|
||||
hiddenSections?: readonly PickerSection<T>[];
|
||||
onChange: (value: T) => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
group?: string;
|
||||
}) {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const rPickerButton = React.useRef<any>(null);
|
||||
const selectedOption = useMemo(
|
||||
() =>
|
||||
findOption(visibleSections, (option) => option.value === value) ??
|
||||
findOption(hiddenSections ?? [], (option) => option.value === value),
|
||||
[visibleSections, hiddenSections, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||
<Popover.Trigger
|
||||
name={group}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={rPickerButton}
|
||||
className={isActive ? "active" : ""}
|
||||
>
|
||||
{options.find((option) => option.value === value)?.icon}
|
||||
{selectedOption?.icon}
|
||||
</Popover.Trigger>
|
||||
{isActive && (
|
||||
<Picker
|
||||
options={options}
|
||||
visibleSections={visibleSections}
|
||||
hiddenSections={hiddenSections}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
}}
|
||||
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
|
||||
@@ -26,13 +26,16 @@
|
||||
background: var(--RadioGroup-background);
|
||||
border: 1px solid var(--RadioGroup-border);
|
||||
|
||||
gap: 2px;
|
||||
|
||||
&__choice {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
min-width: 20px;
|
||||
height: 24px;
|
||||
padding: 0 0.375rem;
|
||||
|
||||
color: var(--RadioGroup-choice-color-off);
|
||||
background: var(--RadioGroup-choice-background-off);
|
||||
|
||||
@@ -1,74 +1,78 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./Range.scss";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export type RangeProps = {
|
||||
updateData: (value: number) => void;
|
||||
app: AppClassProperties;
|
||||
label: React.ReactNode;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
minLabel?: React.ReactNode;
|
||||
hasCommonValue?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const Range = ({ updateData, app, testId }: RangeProps) => {
|
||||
export const Range = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 10,
|
||||
minLabel = min,
|
||||
hasCommonValue = true,
|
||||
testId,
|
||||
}: RangeProps) => {
|
||||
const rangeRef = React.useRef<HTMLInputElement>(null);
|
||||
const valueRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||
let hasCommonOpacity = true;
|
||||
const firstElement = selectedElements.at(0);
|
||||
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
|
||||
if (acc != null && acc !== element.opacity) {
|
||||
hasCommonOpacity = false;
|
||||
}
|
||||
if (acc == null || acc > element.opacity) {
|
||||
return element.opacity;
|
||||
}
|
||||
return acc;
|
||||
}, firstElement?.opacity ?? null);
|
||||
|
||||
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
|
||||
|
||||
useEffect(() => {
|
||||
if (rangeRef.current && valueRef.current) {
|
||||
const rangeElement = rangeRef.current;
|
||||
const valueElement = valueRef.current;
|
||||
const inputWidth = rangeElement.offsetWidth;
|
||||
const thumbWidth = 15; // 15 is the width of the thumb
|
||||
const thumbWidth =
|
||||
parseFloat(
|
||||
getComputedStyle(rangeElement).getPropertyValue(
|
||||
"--slider-thumb-size",
|
||||
),
|
||||
) || 16;
|
||||
const progress = ((value - min) / (max - min || 1)) * 100;
|
||||
const position =
|
||||
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
|
||||
(progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
|
||||
valueElement.style.left = `${position}px`;
|
||||
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
|
||||
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`;
|
||||
}
|
||||
}, [value]);
|
||||
}, [max, min, value]);
|
||||
|
||||
return (
|
||||
<label className="control-label">
|
||||
{t("labels.opacity")}
|
||||
{label}
|
||||
<div className="range-wrapper">
|
||||
<input
|
||||
style={{
|
||||
["--color-slider-track" as string]: hasCommonOpacity
|
||||
["--color-slider-track" as string]: hasCommonValue
|
||||
? undefined
|
||||
: "var(--button-bg)",
|
||||
}}
|
||||
ref={rangeRef}
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="10"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(event) => {
|
||||
updateData(+event.target.value);
|
||||
onChange(+event.target.value);
|
||||
}}
|
||||
value={value}
|
||||
className="range-input"
|
||||
data-testid={testId}
|
||||
/>
|
||||
<div className="value-bubble" ref={valueRef}>
|
||||
{value !== 0 ? value : null}
|
||||
{value !== min ? value : null}
|
||||
</div>
|
||||
<div className="zero-label">0</div>
|
||||
<div className="zero-label">{minLabel}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, "Hello!");
|
||||
act(() => {
|
||||
editor.blur();
|
||||
});
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
mouse.clickOn(text);
|
||||
API.setSelectedElements([text]);
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
@@ -752,7 +750,7 @@ describe("frame resizing behavior", () => {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
height: 103,
|
||||
});
|
||||
|
||||
// Create a rectangle outside the frame
|
||||
|
||||
@@ -236,15 +236,40 @@ const MermaidToExcalidraw = ({
|
||||
<Trans
|
||||
i18nKey="mermaid.description"
|
||||
flowchartLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/flowchart.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
sequenceLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
classLink={(el) => (
|
||||
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/classDiagram.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
erdLink={(el) => (
|
||||
<a
|
||||
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const { elements, files } = ret;
|
||||
const { elements, files = {} } = ret;
|
||||
setError(null);
|
||||
|
||||
data.current = {
|
||||
|
||||
@@ -54,6 +54,7 @@ type InteractiveCanvasProps = {
|
||||
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
|
||||
undefined
|
||||
>;
|
||||
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
|
||||
onPointerMove: Exclude<
|
||||
DOMAttributes<HTMLCanvasElement>["onPointerMove"],
|
||||
undefined
|
||||
@@ -213,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||
height={props.appState.height * props.scale}
|
||||
ref={props.handleCanvasRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onClick={props.onClick}
|
||||
onPointerMove={props.onPointerMove}
|
||||
onPointerUp={props.onPointerUp}
|
||||
onPointerCancel={props.onPointerCancel}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.excalidraw {
|
||||
.dropdown-menu {
|
||||
max-width: 16rem;
|
||||
max-width: 20rem;
|
||||
z-index: 1;
|
||||
|
||||
&--placement-top {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEditorInterface } from "../App";
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
import { RadioGroup } from "../RadioGroup";
|
||||
|
||||
type Props<T> = {
|
||||
@@ -12,6 +13,7 @@ type Props<T> = {
|
||||
onChange: (value: T) => void;
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DropdownMenuItemContentRadio = <T,>({
|
||||
@@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = <T,>({
|
||||
choices,
|
||||
children,
|
||||
name,
|
||||
icon,
|
||||
}: Props<T>) => {
|
||||
const editorInterface = useEditorInterface();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
|
||||
<label className="dropdown-menu-item__text">{children}</label>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<label className="dropdown-menu-item__text">
|
||||
<Ellipsify>{children}</Ellipsify>
|
||||
</label>
|
||||
<RadioGroup
|
||||
name={name}
|
||||
value={value}
|
||||
|
||||
@@ -69,6 +69,11 @@ const modifiedTablerIconProps: Opts = {
|
||||
strokeLinejoin: "round",
|
||||
} as const;
|
||||
|
||||
const arrowheadPreviewIconProps: Opts = {
|
||||
width: 40,
|
||||
height: 20,
|
||||
} as const;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// tabler-icons: present
|
||||
@@ -1291,16 +1296,17 @@ export const ArrowheadNoneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
stroke="currentColor"
|
||||
opacity={0.3}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M12 12l-9 0" />
|
||||
<path d="M21 9l-6 6" />
|
||||
<path d="M21 15l-6 -6" />
|
||||
<path d="M7,11 H19" />
|
||||
<path d="M25,6 L33,16 M33,6 L25,16" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1312,57 +1318,12 @@ export const ArrowheadArrowIcon = React.memo(
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M34 10H6M34 10L27 5M34 10L27 15" />
|
||||
<path d="M27.5 5L34.5 10L27.5 15" />
|
||||
<path d="M7,11 H33 M23,5 L33,11 L23,17" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCircleIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
>
|
||||
<path d="M32 10L6 10" strokeWidth={2} />
|
||||
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCircleOutlineIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M26 10L6 10" />
|
||||
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadBarIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
|
||||
<path
|
||||
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1373,11 +1334,12 @@ export const ArrowheadTriangleIcon = React.memo(
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M32 10L6 10" strokeWidth={2} />
|
||||
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
|
||||
<path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
|
||||
<path d="M23,5 L35,11 L23,17 Z" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1390,12 +1352,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M6,9.5H27" />
|
||||
<path d="M27,5L34,10L27,14Z" fill="none" />
|
||||
<path d="M7,11 H23" />
|
||||
<path d="M23,5 L35,11 L23,17 Z" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
{ width: 40, height: 20 },
|
||||
export const ArrowheadCircleIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
>
|
||||
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
|
||||
<circle cx="29" cy="11" r="4" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCircleOutlineIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M7,11 H25" strokeLinecap="round" />
|
||||
<circle cx="29" cy="11" r="4" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1407,12 +1400,11 @@ export const ArrowheadDiamondIcon = React.memo(
|
||||
fill="currentColor"
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M6,9.5H20" />
|
||||
<path d="M27,5L34,10L27,14L20,9.5Z" />
|
||||
<path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
|
||||
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1425,15 +1417,32 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M6,9.5H20" />
|
||||
<path d="M27,5L34,10L27,14L20,9.5Z" />
|
||||
<path d="M7,11 H21" />
|
||||
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCrowfootIcon = React.memo(
|
||||
export const ArrowheadBarIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M11,11 H31 M31,5 V17" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityOneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
@@ -1443,13 +1452,13 @@ export const ArrowheadCrowfootIcon = React.memo(
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
|
||||
<path d="M35,11 H7 M15,5 V17" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCrowfootOneIcon = React.memo(
|
||||
export const ArrowheadCardinalityManyIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
@@ -1459,13 +1468,13 @@ export const ArrowheadCrowfootOneIcon = React.memo(
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
|
||||
<path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
||||
export const ArrowheadCardinalityOneOrManyIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
@@ -1475,9 +1484,59 @@ export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
|
||||
<path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
|
||||
</g>,
|
||||
{ width: 40, height: 20 },
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
|
||||
<circle cx="15" cy="11" r="4" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
|
||||
({ flip = false }: { flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
|
||||
<circle cx="23" cy="11" r="4" />
|
||||
</g>,
|
||||
arrowheadPreviewIconProps,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
|
||||
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
|
||||
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
|
||||
import {
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
XBrandIcon,
|
||||
settingsIcon,
|
||||
emptyIcon,
|
||||
} from "../icons";
|
||||
import {
|
||||
boltIcon,
|
||||
DeviceDesktopIcon,
|
||||
@@ -427,6 +433,39 @@ const PreferencesToggleToolLockItem = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const PreferencesBoxSelectionModeItem = () => {
|
||||
const { t } = useI18n();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
return (
|
||||
<DropdownMenuItemContentRadio<"contain" | "overlap">
|
||||
name="boxSelectionMode"
|
||||
icon={emptyIcon}
|
||||
value={appState.boxSelectionMode}
|
||||
onChange={(value) => {
|
||||
setAppState({
|
||||
boxSelectionMode: value,
|
||||
});
|
||||
}}
|
||||
choices={[
|
||||
{
|
||||
value: "contain",
|
||||
label: t("labels.boxSelectionContain"),
|
||||
ariaLabel: t("labels.boxSelectionContain"),
|
||||
},
|
||||
{
|
||||
value: "overlap",
|
||||
label: t("labels.boxSelectionOverlap"),
|
||||
ariaLabel: t("labels.boxSelectionOverlap"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t("labels.boxSelectionMode")}
|
||||
</DropdownMenuItemContentRadio>
|
||||
);
|
||||
};
|
||||
|
||||
const PreferencesToggleSnapModeItem = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
@@ -568,6 +607,7 @@ export const Preferences = ({
|
||||
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
|
||||
{children || (
|
||||
<>
|
||||
<PreferencesBoxSelectionModeItem />
|
||||
<PreferencesToggleToolLockItem />
|
||||
<PreferencesToggleSnapModeItem />
|
||||
<PreferencesToggleGridModeItem />
|
||||
@@ -585,6 +625,7 @@ export const Preferences = ({
|
||||
};
|
||||
|
||||
Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
|
||||
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
|
||||
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
|
||||
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
|
||||
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
|
||||
|
||||
@@ -34,9 +34,10 @@
|
||||
--popup-text-color: #000;
|
||||
--popup-text-inverted-color: #fff;
|
||||
--select-highlight-color: #{$color-blue-5};
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
|
||||
|
||||
--button-hover-bg: var(--color-surface-high);
|
||||
--button-active-bg: var(--color-surface-high);
|
||||
@@ -210,9 +211,6 @@
|
||||
--popup-text-color: #{$color-gray-4};
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--select-highlight-color: #{$color-blue-4};
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MIME_TYPES,
|
||||
cloneJSON,
|
||||
SVG_DOCUMENT_PREAMBLE,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
@@ -49,6 +50,7 @@ export const prepareElementsForExport = (
|
||||
exportSelectionOnly: boolean,
|
||||
) => {
|
||||
elements = getNonDeletedElements(elements);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const isExportingSelection =
|
||||
exportSelectionOnly &&
|
||||
@@ -71,7 +73,11 @@ export const prepareElementsForExport = (
|
||||
isFrameLikeElement(exportedElements[0])
|
||||
) {
|
||||
exportingFrame = exportedElements[0];
|
||||
exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
|
||||
exportedElements = getElementsOverlappingFrame(
|
||||
elements,
|
||||
exportingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
} else if (exportedElements.length > 1) {
|
||||
exportedElements = getSelectedElements(
|
||||
elements,
|
||||
|
||||
@@ -20,10 +20,6 @@ export const resaveAsImageWithScene = async (
|
||||
) => {
|
||||
const fileHandleType = getFileHandleType(fileHandle);
|
||||
|
||||
if (Math.random() < 1) {
|
||||
throw new Error("OLALALALA");
|
||||
}
|
||||
|
||||
if (!isImageFileHandleType(fileHandleType)) {
|
||||
throw new Error(
|
||||
"fileHandle should exist and should be of type svg or png when resaving",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
getNonDeletedElements,
|
||||
normalizeArrowhead,
|
||||
isPointInElement,
|
||||
isValidPolygon,
|
||||
projectFixedPointOntoDiagonal,
|
||||
@@ -250,7 +251,9 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`could not repair binding for element`);
|
||||
console.error(
|
||||
`Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error repairing binding:", error);
|
||||
}
|
||||
@@ -426,7 +429,8 @@ export const restoreElement = (
|
||||
// @ts-ignore LEGACY type
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case "draw":
|
||||
const { startArrowhead = null, endArrowhead = null } = element;
|
||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||
const endArrowhead = normalizeArrowhead(element.endArrowhead);
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
@@ -458,7 +462,11 @@ export const restoreElement = (
|
||||
...getSizeFromPoints(points),
|
||||
});
|
||||
case "arrow": {
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||
const endArrowhead =
|
||||
element.endArrowhead === undefined
|
||||
? "arrow"
|
||||
: normalizeArrowhead(element.endArrowhead);
|
||||
const x: number | undefined = element.x;
|
||||
const y: number | undefined = element.y;
|
||||
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
|
||||
@@ -928,6 +936,12 @@ export const restoreAppState = (
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
const boxSelectionMode =
|
||||
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
|
||||
if (boxSelectionMode !== undefined) {
|
||||
nextAppState.boxSelectionMode = boxSelectionMode;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextAppState,
|
||||
cursorButton: localAppState?.cursorButton || "up",
|
||||
|
||||
@@ -87,10 +87,22 @@ export class HistoryChangedEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export type HistoryBeforeRecordListener = (delta: StoreDelta) => void;
|
||||
export type HistoryPerformDirection = "undo" | "redo";
|
||||
export type HistoryEffectiveDeltaResolverContext = {
|
||||
direction: HistoryPerformDirection;
|
||||
};
|
||||
export type HistoryEffectiveDeltaResolver = (
|
||||
delta: HistoryDelta,
|
||||
context: HistoryEffectiveDeltaResolverContext,
|
||||
) => HistoryDelta;
|
||||
|
||||
export class History {
|
||||
public readonly onHistoryChangedEmitter = new Emitter<
|
||||
[HistoryChangedEvent]
|
||||
>();
|
||||
private readonly onBeforeRecordEmitter = new Emitter<[StoreDelta]>();
|
||||
private effectiveDeltaResolver: HistoryEffectiveDeltaResolver | null = null;
|
||||
|
||||
public readonly undoStack: HistoryDelta[] = [];
|
||||
public readonly redoStack: HistoryDelta[] = [];
|
||||
@@ -105,6 +117,20 @@ export class History {
|
||||
|
||||
constructor(private readonly store: Store) {}
|
||||
|
||||
/**
|
||||
* Registers a hook that runs before a durable delta is converted
|
||||
* into a history entry.
|
||||
*/
|
||||
public onBeforeRecord(callback: HistoryBeforeRecordListener) {
|
||||
return this.onBeforeRecordEmitter.on(callback);
|
||||
}
|
||||
|
||||
public setEffectiveDeltaResolver(
|
||||
resolver: HistoryEffectiveDeltaResolver | null,
|
||||
) {
|
||||
this.effectiveDeltaResolver = resolver;
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.undoStack.length = 0;
|
||||
this.redoStack.length = 0;
|
||||
@@ -119,6 +145,8 @@ export class History {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onBeforeRecordEmitter.trigger(delta);
|
||||
|
||||
// construct history entry, so once it's emitted, it's not recorded again
|
||||
const historyDelta = HistoryDelta.inverse(delta);
|
||||
|
||||
@@ -131,15 +159,14 @@ export class History {
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
this.onHistoryChangedEmitter.trigger(
|
||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||
);
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
public undo(elements: SceneElementsMap, appState: AppState) {
|
||||
return this.perform(
|
||||
elements,
|
||||
appState,
|
||||
"undo",
|
||||
() => History.pop(this.undoStack),
|
||||
(entry: HistoryDelta) => History.push(this.redoStack, entry),
|
||||
);
|
||||
@@ -149,6 +176,7 @@ export class History {
|
||||
return this.perform(
|
||||
elements,
|
||||
appState,
|
||||
"redo",
|
||||
() => History.pop(this.redoStack),
|
||||
(entry: HistoryDelta) => History.push(this.undoStack, entry),
|
||||
);
|
||||
@@ -157,6 +185,7 @@ export class History {
|
||||
private perform(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
direction: HistoryPerformDirection,
|
||||
pop: () => HistoryDelta | null,
|
||||
push: (entry: HistoryDelta) => void,
|
||||
): [SceneElementsMap, AppState] | void {
|
||||
@@ -177,9 +206,15 @@ export class History {
|
||||
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyDelta) {
|
||||
// Roundtrip invariant: whichever delta we execute must be the one that
|
||||
// continues through applyLatestChanges -> inverse -> opposite stack.
|
||||
let entryToPush = historyDelta;
|
||||
try {
|
||||
const effectiveDelta = this.resolveEffectiveDelta(historyDelta, {
|
||||
direction,
|
||||
});
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
||||
effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
||||
|
||||
const prevElements = prevSnapshot.elements;
|
||||
const nextSnapshot = prevSnapshot.maybeClone(
|
||||
@@ -190,7 +225,7 @@ export class History {
|
||||
|
||||
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||
const delta = HistoryDelta.applyLatestChanges(
|
||||
historyDelta,
|
||||
effectiveDelta,
|
||||
prevElements,
|
||||
nextElements,
|
||||
);
|
||||
@@ -203,12 +238,12 @@ export class History {
|
||||
delta,
|
||||
});
|
||||
|
||||
historyDelta = delta;
|
||||
entryToPush = delta;
|
||||
}
|
||||
|
||||
prevSnapshot = nextSnapshot;
|
||||
} finally {
|
||||
push(historyDelta);
|
||||
push(entryToPush);
|
||||
}
|
||||
|
||||
if (containsVisibleChange) {
|
||||
@@ -222,12 +257,16 @@ export class History {
|
||||
} finally {
|
||||
// trigger the history change event before returning completely
|
||||
// also trigger it just once, no need doing so on each entry
|
||||
this.onHistoryChangedEmitter.trigger(
|
||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||
);
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private emitHistoryChanged() {
|
||||
this.onHistoryChangedEmitter.trigger(
|
||||
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||
);
|
||||
}
|
||||
|
||||
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
|
||||
if (!stack.length) {
|
||||
return null;
|
||||
@@ -246,4 +285,15 @@ export class History {
|
||||
const inversedEntry = HistoryDelta.inverse(entry);
|
||||
return stack.push(inversedEntry);
|
||||
}
|
||||
|
||||
private resolveEffectiveDelta(
|
||||
delta: HistoryDelta,
|
||||
context: HistoryEffectiveDeltaResolverContext,
|
||||
): HistoryDelta {
|
||||
if (!this.effectiveDeltaResolver) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
return this.effectiveDeltaResolver(delta, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,51 @@ import { getDefaultAppState } from "../appState";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
type AppStateSelector =
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown);
|
||||
|
||||
const getSelectedValue = (appState: AppState, selector: AppStateSelector) => {
|
||||
if (typeof selector === "function") {
|
||||
return selector(appState);
|
||||
}
|
||||
if (Array.isArray(selector)) {
|
||||
return appState;
|
||||
}
|
||||
return appState[selector];
|
||||
};
|
||||
|
||||
const getLatestValue = (
|
||||
api: ReturnType<typeof useExcalidrawAPI>,
|
||||
selector: AppStateSelector,
|
||||
_internal: boolean,
|
||||
) => {
|
||||
if (api?.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let appState = api?.getAppState();
|
||||
if (!appState) {
|
||||
if (!_internal) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
|
||||
);
|
||||
// fall back in case there's a bug so we don't break the app
|
||||
// (internal components using this internal useAppStateValue expect
|
||||
// non-undefined values on init)
|
||||
appState = Object.assign(
|
||||
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
|
||||
getDefaultAppState(),
|
||||
);
|
||||
}
|
||||
|
||||
return getSelectedValue(appState, selector);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to specific appState changes. The component re-renders
|
||||
* only when the specified prop(s) change — not on every appState update.
|
||||
@@ -19,92 +64,109 @@ import type { AppState } from "../types";
|
||||
* - `(keyof AppState)[]` → whole `AppState`
|
||||
* - selector function → selector's return type `T`
|
||||
*
|
||||
* Calls the optional callback with the latest value on every change (not called
|
||||
* on initial render).
|
||||
*
|
||||
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
|
||||
* value once available.
|
||||
*/
|
||||
export function useAppStateValue<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback?: (value: AppState[K], appState: AppState) => void,
|
||||
_internal?: boolean,
|
||||
): AppState[K];
|
||||
export function useAppStateValue(
|
||||
props: (keyof AppState)[],
|
||||
callback?: (props: AppState, appState: AppState) => void,
|
||||
_internal?: boolean,
|
||||
): AppState;
|
||||
export function useAppStateValue<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
callback?: (value: T, appState: AppState) => void,
|
||||
_internal?: boolean,
|
||||
): T;
|
||||
export function useAppStateValue(
|
||||
selector:
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown),
|
||||
callback?: (value: any, appState: AppState) => void,
|
||||
selector: AppStateSelector,
|
||||
_internal: boolean = true,
|
||||
): unknown {
|
||||
const api = useExcalidrawAPI();
|
||||
const [, rerender] = useState(0);
|
||||
|
||||
const getLatestValue = () => {
|
||||
let appState = api?.getAppState();
|
||||
if (!appState) {
|
||||
if (!_internal) {
|
||||
return undefined;
|
||||
}
|
||||
console.warn(
|
||||
"useAppStateValue: excalidrawAPI not defined yet for internal component in which case it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
|
||||
);
|
||||
// fall back in case there's a bug so we don't break the app
|
||||
// (internal components using this internal useAppStateValue expect
|
||||
// non-undefined values on init)
|
||||
appState = Object.assign(
|
||||
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
|
||||
getDefaultAppState(),
|
||||
);
|
||||
}
|
||||
if (typeof selector === "function") {
|
||||
return selector(appState);
|
||||
}
|
||||
if (Array.isArray(selector)) {
|
||||
return appState;
|
||||
}
|
||||
return appState[selector];
|
||||
};
|
||||
|
||||
const [value, setValue] = useState<unknown>(getLatestValue);
|
||||
|
||||
const stateRef = useRef({
|
||||
selector,
|
||||
callback,
|
||||
isInitialized: !!api,
|
||||
latestValue: value,
|
||||
});
|
||||
const stateRef = useRef<{
|
||||
selector: AppStateSelector;
|
||||
isInitialized: boolean;
|
||||
latestValue: unknown;
|
||||
} | null>(null);
|
||||
if (!stateRef.current) {
|
||||
stateRef.current = {
|
||||
selector,
|
||||
isInitialized: !!api,
|
||||
latestValue: getLatestValue(api, selector, _internal),
|
||||
};
|
||||
}
|
||||
stateRef.current.selector = selector;
|
||||
stateRef.current.callback = callback;
|
||||
if (!stateRef.current.isInitialized && api) {
|
||||
if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
|
||||
stateRef.current.isInitialized = true;
|
||||
stateRef.current.latestValue = getLatestValue();
|
||||
stateRef.current.latestValue = getLatestValue(api, selector, _internal);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
const currentStateRef = stateRef.current;
|
||||
if (!api || api.isDestroyed || !currentStateRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
return api.onStateChange(
|
||||
stateRef.current.selector,
|
||||
(newValue: any, state: AppState) => {
|
||||
stateRef.current.latestValue = newValue;
|
||||
stateRef.current.callback?.(newValue, state);
|
||||
setValue(newValue);
|
||||
},
|
||||
);
|
||||
return api.onStateChange(currentStateRef.selector, (newValue: any) => {
|
||||
currentStateRef.latestValue = newValue;
|
||||
rerender((value) => value + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return stateRef.current.latestValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to specific appState changes without causing component rerenders.
|
||||
*
|
||||
* The callback is called on every matching change, but also on initial render
|
||||
* so you can initialize your state.
|
||||
*/
|
||||
export function useOnAppStateChange<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback: (value: AppState[K], appState: AppState) => void,
|
||||
): undefined;
|
||||
export function useOnAppStateChange(
|
||||
props: (keyof AppState)[],
|
||||
callback: (props: AppState, appState: AppState) => void,
|
||||
): undefined;
|
||||
export function useOnAppStateChange<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
callback: (value: T, appState: AppState) => void,
|
||||
): undefined;
|
||||
export function useOnAppStateChange(
|
||||
selector: AppStateSelector,
|
||||
callback: (value: any, appState: AppState) => void,
|
||||
): undefined {
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
const stateRef = useRef({
|
||||
selector,
|
||||
callback,
|
||||
});
|
||||
stateRef.current.selector = selector;
|
||||
stateRef.current.callback = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || api.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
stateRef.current.callback(
|
||||
getLatestValue(api, stateRef.current.selector, true),
|
||||
api.getAppState(),
|
||||
);
|
||||
|
||||
return api.onStateChange(
|
||||
stateRef.current.selector,
|
||||
(newValue: any, state: AppState) => {
|
||||
stateRef.current.callback(newValue, state);
|
||||
},
|
||||
);
|
||||
}, [api]);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
@@ -12,7 +18,10 @@ import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollab
|
||||
import MainMenu from "./components/main-menu/MainMenu";
|
||||
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
||||
import { defaultLang } from "./i18n";
|
||||
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
|
||||
import {
|
||||
useAppStateValue as _useAppStateValue,
|
||||
useOnAppStateChange as _useOnAppStateChange,
|
||||
} from "./hooks/useAppStateValue";
|
||||
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
||||
import polyfill from "./polyfill";
|
||||
|
||||
@@ -31,8 +40,8 @@ polyfill();
|
||||
|
||||
/**
|
||||
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
|
||||
* on it, such as `useAppStateValue()`) to work outside the <Excalidraw>
|
||||
* component tree.
|
||||
* on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work
|
||||
* outside the <Excalidraw> component tree.
|
||||
*/
|
||||
export const ExcalidrawAPIProvider = ({
|
||||
children,
|
||||
@@ -57,6 +66,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
initialData,
|
||||
onExcalidrawAPI,
|
||||
onMount,
|
||||
onUnmount,
|
||||
onInitialize,
|
||||
isCollaborating = false,
|
||||
onPointerUpdate,
|
||||
@@ -119,12 +129,16 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
}
|
||||
|
||||
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
|
||||
|
||||
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
|
||||
onExcalidrawAPIRef.current = onExcalidrawAPI;
|
||||
|
||||
const handleExcalidrawAPI = useCallback(
|
||||
(api: ExcalidrawImperativeAPI) => {
|
||||
(api: ExcalidrawImperativeAPI | null) => {
|
||||
setExcalidrawAPI?.(api);
|
||||
onExcalidrawAPI?.(api);
|
||||
onExcalidrawAPIRef.current?.(api);
|
||||
},
|
||||
[onExcalidrawAPI, setExcalidrawAPI],
|
||||
[setExcalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -162,6 +176,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
initialData={initialData}
|
||||
onExcalidrawAPI={handleExcalidrawAPI}
|
||||
onMount={onMount}
|
||||
onUnmount={onUnmount}
|
||||
onInitialize={onInitialize}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
@@ -367,6 +382,8 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC
|
||||
export { getDataURL } from "./data/blob";
|
||||
export { isElementLink } from "@excalidraw/element";
|
||||
|
||||
export { Fonts } from "./fonts/Fonts";
|
||||
|
||||
export { setCustomTextMetricsProvider } from "@excalidraw/element";
|
||||
|
||||
export { CommandPalette } from "./components/CommandPalette/CommandPalette";
|
||||
@@ -378,7 +395,7 @@ export {
|
||||
} from "./charts";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// useAppStateValue() wrapper for host apps for the return type to reflect the
|
||||
// useExcalidrawStateValue() wrapper for host apps for the return type to reflect the
|
||||
// the potentially `undefined` value for initial render before the excalidrawAPI
|
||||
// is ready.
|
||||
//
|
||||
@@ -388,25 +405,23 @@ export {
|
||||
* @param prop - appState prop(s) to subscribe to, or a selector function.
|
||||
* NOTE `prop/selector` is memoized and will not change after initial render
|
||||
*/
|
||||
export function useAppStateValue<K extends keyof AppState>(
|
||||
export function useExcalidrawStateValue<K extends keyof AppState>(
|
||||
prop: K,
|
||||
callback?: (value: AppState[K], appState: AppState) => void,
|
||||
): AppState[K] | undefined;
|
||||
export function useAppStateValue<T extends keyof AppState>(
|
||||
export function useExcalidrawStateValue<T extends keyof AppState>(
|
||||
props: T[],
|
||||
callback?: (values: AppState, appState: AppState) => void,
|
||||
): AppState | undefined;
|
||||
export function useAppStateValue<T>(
|
||||
export function useExcalidrawStateValue<T>(
|
||||
selector: (appState: AppState) => T,
|
||||
callback?: (value: T, appState: AppState) => void,
|
||||
): T | undefined;
|
||||
export function useAppStateValue(
|
||||
export function useExcalidrawStateValue(
|
||||
selector:
|
||||
| keyof AppState
|
||||
| (keyof AppState)[]
|
||||
| ((appState: AppState) => unknown),
|
||||
callback?: (value: any, appState: AppState) => void,
|
||||
) {
|
||||
return _useAppStateValue(selector as any, callback, false);
|
||||
return _useAppStateValue(selector as any, false);
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export { _useOnAppStateChange as useOnExcalidrawStateChange };
|
||||
|
||||
@@ -53,7 +53,14 @@
|
||||
"arrowhead_crowfoot_many": "Crow's foot (many)",
|
||||
"arrowhead_crowfoot_one": "Crow's foot (one)",
|
||||
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
|
||||
"arrowhead_cardinality_one": "Cardinality (one)",
|
||||
"arrowhead_cardinality_many": "Cardinality (many)",
|
||||
"arrowhead_cardinality_one_or_many": "Cardinality (one or many)",
|
||||
"arrowhead_cardinality_exactly_one": "Cardinality (exactly one)",
|
||||
"arrowhead_cardinality_zero_or_one": "Cardinality (zero or one)",
|
||||
"arrowhead_cardinality_zero_or_many": "Cardinality (zero or many)",
|
||||
"more_options": "More options",
|
||||
"cardinality": "Cardinality",
|
||||
"arrowtypes": "Arrow type",
|
||||
"arrowtype_sharp": "Sharp arrow",
|
||||
"arrowtype_round": "Curved arrow",
|
||||
@@ -178,6 +185,9 @@
|
||||
"shapeSwitch": "Switch shape",
|
||||
"preferences": "Preferences",
|
||||
"preferences_toolLock": "Tool lock",
|
||||
"boxSelectionMode": "Select on",
|
||||
"boxSelectionContain": "Wrap",
|
||||
"boxSelectionOverlap": "Overlap",
|
||||
"arrowBinding": "Arrow binding",
|
||||
"midpointSnapping": "Snap to midpoints"
|
||||
},
|
||||
@@ -624,7 +634,7 @@
|
||||
"mermaid": {
|
||||
"title": "Mermaid to Excalidraw",
|
||||
"button": "Insert",
|
||||
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.",
|
||||
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.",
|
||||
"syntax": "Mermaid Syntax",
|
||||
"preview": "Preview",
|
||||
"label": "Mermaid",
|
||||
@@ -654,7 +664,7 @@
|
||||
"placeholder": {
|
||||
"title": "Let's design your diagram",
|
||||
"description": "Describe the diagram you want to create, and we'll generate it for you.",
|
||||
"hint": "At the moment we know Flowchart, Sequence, and Class diagrams."
|
||||
"hint": "At the moment we know Flowchart, Sequence, Class, State, and Entity Relationship diagrams."
|
||||
},
|
||||
"preview": "Preview",
|
||||
"insert": "Insert",
|
||||
|
||||
@@ -83,13 +83,13 @@
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/element": "0.18.0",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
|
||||
"@excalidraw/mermaid-to-excalidraw": "2.2.2",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
maxBindingDistance_simple,
|
||||
isTextElement,
|
||||
LinearElementEditor,
|
||||
getActiveTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { renderSelectionElement } from "@excalidraw/element";
|
||||
@@ -58,6 +59,8 @@ import {
|
||||
isFocusPointVisible,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { EditorInterface } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
TransformHandles,
|
||||
TransformHandleType,
|
||||
@@ -86,6 +89,10 @@ import {
|
||||
} from "../scene/scrollbars";
|
||||
|
||||
import { getClientColor, renderRemoteCursors } from "../clients";
|
||||
import {
|
||||
getTextAutoResizeHandle,
|
||||
getTextBoxPadding,
|
||||
} from "../textAutoResizeHandle";
|
||||
|
||||
import {
|
||||
bootstrapCanvas,
|
||||
@@ -1149,6 +1156,7 @@ const renderLinearPointHandles = (
|
||||
points[idx],
|
||||
idx,
|
||||
appState.zoom,
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
renderSingleLinearPoint(
|
||||
@@ -1489,21 +1497,58 @@ const renderTextBox = (
|
||||
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||
) => {
|
||||
context.save();
|
||||
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
||||
const padding = getTextBoxPadding(appState.zoom.value);
|
||||
const width = text.width + padding * 2;
|
||||
const height = text.height + padding * 2;
|
||||
const cx = text.x + width / 2;
|
||||
const cy = text.y + height / 2;
|
||||
const shiftX = -(width / 2 + padding);
|
||||
const shiftY = -(height / 2 + padding);
|
||||
const cx = text.x + text.width / 2;
|
||||
const cy = text.y + text.height / 2;
|
||||
const shiftX = -(text.width / 2 + padding);
|
||||
const shiftY = -(text.height / 2 + padding);
|
||||
context.translate(cx + appState.scrollX, cy + appState.scrollY);
|
||||
context.rotate(text.angle);
|
||||
context.lineWidth = 1 / appState.zoom.value;
|
||||
context.strokeStyle = selectionColor;
|
||||
context.globalAlpha = 0.5;
|
||||
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
|
||||
context.strokeRect(shiftX, shiftY, width, height);
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderResetAutoResizeHandle = (
|
||||
text: NonDeleted<ExcalidrawTextElement>,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||
formFactor: EditorInterface["formFactor"],
|
||||
) => {
|
||||
const autoResizeHandle = getTextAutoResizeHandle(
|
||||
text,
|
||||
appState.zoom.value,
|
||||
formFactor,
|
||||
);
|
||||
|
||||
if (!autoResizeHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = 0.5;
|
||||
context.lineWidth = 1.5 / appState.zoom.value;
|
||||
context.lineCap = "round";
|
||||
context.strokeStyle = selectionColor;
|
||||
context.beginPath();
|
||||
context.moveTo(
|
||||
autoResizeHandle.start[0] + appState.scrollX,
|
||||
autoResizeHandle.start[1] + appState.scrollY,
|
||||
);
|
||||
context.lineTo(
|
||||
autoResizeHandle.end[0] + appState.scrollX,
|
||||
autoResizeHandle.end[1] + appState.scrollY,
|
||||
);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const _renderInteractiveScene = ({
|
||||
app,
|
||||
canvas,
|
||||
@@ -1584,10 +1629,19 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
appState.editingTextElement &&
|
||||
isTextElement(appState.editingTextElement)
|
||||
) {
|
||||
const activeTextElement = getActiveTextElement(selectedElements, appState);
|
||||
|
||||
if (activeTextElement && !activeTextElement.autoResize) {
|
||||
renderResetAutoResizeHandle(
|
||||
activeTextElement,
|
||||
context,
|
||||
appState,
|
||||
renderConfig.selectionColor,
|
||||
editorInterface.formFactor,
|
||||
);
|
||||
}
|
||||
|
||||
if (appState.editingTextElement) {
|
||||
const textElement = allElementsMap.get(appState.editingTextElement.id) as
|
||||
| ExcalidrawTextElement
|
||||
| undefined;
|
||||
|
||||
@@ -157,7 +157,11 @@ const prepareElementsForRender = ({
|
||||
let nextElements: readonly ExcalidrawElement[];
|
||||
|
||||
if (exportingFrame) {
|
||||
nextElements = getElementsOverlappingFrame(elements, exportingFrame);
|
||||
nextElements = getElementsOverlappingFrame(
|
||||
elements,
|
||||
exportingFrame,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
} else if (frameRendering.enabled && frameRendering.name) {
|
||||
nextElements = addFrameLabelsAsTextElements(elements, {
|
||||
exportWithDarkMode,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
|
||||
"flowchart TD
|
||||
|
||||
@@ -13,6 +13,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
@@ -1086,6 +1087,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -1300,6 +1302,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -1631,6 +1634,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -1962,6 +1966,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2176,6 +2181,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2417,6 +2423,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2715,6 +2722,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3087,6 +3095,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3580,6 +3589,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3903,6 +3913,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -4226,6 +4237,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -4358,7 +4370,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 760410951,
|
||||
"versionNonce": 1006504105,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"y": 0,
|
||||
@@ -4383,14 +4395,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 238820263,
|
||||
"seed": 400692809,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 1006504105,
|
||||
"versionNonce": 289600103,
|
||||
"width": 20,
|
||||
"x": 20,
|
||||
"y": 30,
|
||||
@@ -4637,6 +4649,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
@@ -5854,6 +5867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
@@ -6864,7 +6878,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 747212839,
|
||||
"versionNonce": 1723083209,
|
||||
"width": 10,
|
||||
"x": -10,
|
||||
"y": 0,
|
||||
@@ -6891,14 +6905,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 238820263,
|
||||
"seed": 400692809,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 1723083209,
|
||||
"versionNonce": 760410951,
|
||||
"width": 10,
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
@@ -7122,6 +7136,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
@@ -7811,6 +7826,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
@@ -8802,6 +8818,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -439,6 +440,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -855,6 +857,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -1421,6 +1424,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -1628,6 +1632,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2012,6 +2017,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2257,6 +2263,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2437,6 +2444,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -2762,6 +2770,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3017,6 +3026,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3258,6 +3268,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3494,6 +3505,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -3752,6 +3764,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -4066,6 +4079,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -4502,6 +4516,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -4785,6 +4800,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -5061,6 +5077,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -5269,6 +5286,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -5469,6 +5487,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -5862,6 +5881,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -6159,6 +6179,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -6947,6 +6968,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -7281,6 +7303,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -7560,6 +7583,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -7795,6 +7819,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -8035,6 +8060,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -8215,6 +8241,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -8395,6 +8422,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -8575,6 +8603,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -8807,6 +8836,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -9037,6 +9067,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -9229,6 +9260,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -9461,6 +9493,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -9641,6 +9674,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -9871,6 +9905,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -10051,6 +10086,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -10243,6 +10279,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -10423,6 +10460,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -10954,6 +10992,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -11234,6 +11273,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -11357,6 +11397,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -11557,6 +11598,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -11876,6 +11918,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -12305,6 +12348,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -12945,6 +12989,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -13071,6 +13116,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -13702,6 +13748,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -14041,6 +14088,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -14305,6 +14353,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -14428,6 +14477,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -14792,6 +14842,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
@@ -14915,6 +14966,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ExcalidrawAPIContext } from "../components/App";
|
||||
import { AppStateObserver } from "../components/AppStateObserver";
|
||||
import {
|
||||
useAppStateValue,
|
||||
useOnAppStateChange,
|
||||
} from "../hooks/useAppStateValue";
|
||||
|
||||
import type { AppState, ExcalidrawImperativeAPI } from "../types";
|
||||
|
||||
const createAppState = (): AppState => ({
|
||||
...getDefaultAppState(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
});
|
||||
|
||||
const createMockAPI = (initialState: AppState) => {
|
||||
let state = initialState;
|
||||
const observer = new AppStateObserver(() => state);
|
||||
|
||||
return {
|
||||
api: {
|
||||
isDestroyed: false,
|
||||
getAppState: () => state,
|
||||
onStateChange: observer.onStateChange,
|
||||
} as Pick<
|
||||
ExcalidrawImperativeAPI,
|
||||
"isDestroyed" | "getAppState" | "onStateChange"
|
||||
> as ExcalidrawImperativeAPI,
|
||||
updateAppState: (partial: Partial<AppState>) => {
|
||||
const prevState = state;
|
||||
state = { ...state, ...partial };
|
||||
observer.flush(prevState);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("app state hooks", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("useAppStateValue rerenders when the selected value changes", () => {
|
||||
const renderSpy = vi.fn();
|
||||
const { api, updateAppState } = createMockAPI(createAppState());
|
||||
|
||||
const ValueConsumer = () => {
|
||||
const value = useAppStateValue("viewModeEnabled");
|
||||
renderSpy(value);
|
||||
return <div data-testid="value">{String(value)}</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<ExcalidrawAPIContext.Provider value={api}>
|
||||
<ValueConsumer />
|
||||
</ExcalidrawAPIContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("value").textContent).toBe("false");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
updateAppState({ viewModeEnabled: true });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("value").textContent).toBe("true");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("useOnAppStateChange notifies without rerendering", () => {
|
||||
const renderSpy = vi.fn();
|
||||
const callback = vi.fn();
|
||||
const { api, updateAppState } = createMockAPI(createAppState());
|
||||
|
||||
const ChangeConsumer = () => {
|
||||
const value = useOnAppStateChange("viewModeEnabled", callback);
|
||||
renderSpy(value);
|
||||
return <div data-testid="value">{String(value)}</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<ExcalidrawAPIContext.Provider value={api}>
|
||||
<ChangeConsumer />
|
||||
</ExcalidrawAPIContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("value").textContent).toBe("undefined");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
false,
|
||||
expect.objectContaining({ viewModeEnabled: false }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
updateAppState({ viewModeEnabled: true });
|
||||
});
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ viewModeEnabled: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -200,6 +200,26 @@ describe("restoreElements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should normalize legacy crowfoot arrowheads on restore", () => {
|
||||
const arrowElement = API.createElement({
|
||||
type: "arrow",
|
||||
});
|
||||
|
||||
const restoredArrow = restore.restoreElements(
|
||||
[
|
||||
{
|
||||
...arrowElement,
|
||||
startArrowhead: "crowfoot_one",
|
||||
endArrowhead: "crowfoot_one_or_many",
|
||||
} as any,
|
||||
],
|
||||
null,
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(restoredArrow.startArrowhead).toBe("cardinality_one");
|
||||
expect(restoredArrow.endArrowhead).toBe("cardinality_one_or_many");
|
||||
});
|
||||
|
||||
it("should strip element if restore fails", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
elementCenterPoint,
|
||||
getCommonBounds,
|
||||
getElementPointsCoords,
|
||||
getLineHeightInPx,
|
||||
} from "@excalidraw/element";
|
||||
import { cropElement } from "@excalidraw/element";
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
isTextElement,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
@@ -516,8 +517,17 @@ export class UI {
|
||||
UI.clickTool(type);
|
||||
|
||||
if (type === "text") {
|
||||
const clickY = h.state.gridModeEnabled
|
||||
? y
|
||||
: y +
|
||||
getLineHeightInPx(
|
||||
h.state.currentItemFontSize,
|
||||
getLineHeight(h.state.currentItemFontFamily),
|
||||
) /
|
||||
2;
|
||||
|
||||
mouse.reset();
|
||||
mouse.click(x, y);
|
||||
mouse.click(x, clickY);
|
||||
} else if ((type === "line" || type === "arrow") && points.length > 2) {
|
||||
points.forEach((point) => {
|
||||
mouse.reset();
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("history", () => {
|
||||
expect.objectContaining({ id: rect2.id, isDeleted: true }),
|
||||
]);
|
||||
|
||||
mouse.downAt(0, 0);
|
||||
mouse.downAt(-10, -10);
|
||||
mouse.moveTo(25, 25);
|
||||
mouse.moveTo(50, 50);
|
||||
mouse.upAt(50, 50);
|
||||
@@ -2971,6 +2971,82 @@ describe("history", () => {
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
});
|
||||
|
||||
// TODO mark with "noncritical" tag once we migrate to vitest 4
|
||||
it.skip("should support undo and redo when escape unwinds nested group editing", async () => {
|
||||
const rectA = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["inner", "outer"],
|
||||
x: 0,
|
||||
});
|
||||
const rectB = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["outer"],
|
||||
x: 100,
|
||||
});
|
||||
const rectC = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["inner", "outer"],
|
||||
x: 200,
|
||||
});
|
||||
|
||||
API.setElements([rectA, rectB, rectC]);
|
||||
mouse.select(rectA);
|
||||
mouse.doubleClickOn(rectA);
|
||||
mouse.doubleClickOn(rectA);
|
||||
|
||||
assertSelectedElements([rectA]);
|
||||
expect(h.state.editingGroupId).toBe("inner");
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
assertSelectedElements([rectA, rectC]);
|
||||
expect(h.state.editingGroupId).toBe("outer");
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
assertSelectedElements([rectA, rectB, rectC]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({ outer: true });
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
expect(API.getSelectedElements()).toEqual([]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
expect(API.getUndoStack().length).toBe(6);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements([rectA, rectB, rectC]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({ outer: true });
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements([rectA, rectC]);
|
||||
expect(h.state.editingGroupId).toBe("outer");
|
||||
|
||||
Keyboard.undo();
|
||||
assertSelectedElements([rectA]);
|
||||
expect(h.state.editingGroupId).toBe("inner");
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements([rectA, rectC]);
|
||||
expect(h.state.editingGroupId).toBe("outer");
|
||||
|
||||
Keyboard.redo();
|
||||
assertSelectedElements([rectA, rectB, rectC]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({ outer: true });
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getSelectedElements()).toEqual([]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
});
|
||||
|
||||
it("should iterate through the history when selected or editing linear element was remotely deleted", async () => {
|
||||
// create three point arrow
|
||||
UI.clickTool("arrow");
|
||||
|
||||
@@ -41,13 +41,15 @@ describe("event callbacks", () => {
|
||||
|
||||
await render(
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) =>
|
||||
lifecyclePromise.resolve({
|
||||
api: api as ExcalidrawImperativeAPI,
|
||||
mount: api.onEvent("editor:mount"),
|
||||
initialize: api.onEvent("editor:initialize"),
|
||||
})
|
||||
}
|
||||
onExcalidrawAPI={(api) => {
|
||||
if (api) {
|
||||
lifecyclePromise.resolve({
|
||||
api,
|
||||
mount: api.onEvent("editor:mount"),
|
||||
initialize: api.onEvent("editor:initialize"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ describe("regression tests", () => {
|
||||
mouse.reset();
|
||||
mouse.down();
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.restorePosition(...end);
|
||||
mouse.restorePosition(end[0] + 3, end[1] + 3);
|
||||
mouse.up();
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
@@ -519,7 +519,7 @@ describe("regression tests", () => {
|
||||
mouse.reset();
|
||||
mouse.down();
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.restorePosition(...end);
|
||||
mouse.restorePosition(end[0] + 3, end[1] + 3);
|
||||
mouse.up();
|
||||
|
||||
for (const element of h.elements) {
|
||||
@@ -537,7 +537,7 @@ describe("regression tests", () => {
|
||||
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
|
||||
mouse.down();
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.restorePosition(...end);
|
||||
mouse.restorePosition(end[0] + 3, end[1] + 3);
|
||||
mouse.up();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { KEYS, reseed } from "@excalidraw/common";
|
||||
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
|
||||
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
|
||||
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { SHAPES } from "../components/shapes";
|
||||
|
||||
@@ -12,6 +14,7 @@ import * as StaticScene from "../renderer/staticScene";
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
@@ -39,6 +42,19 @@ const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const getOutlineBounds = (element: ReturnType<typeof API.createElement>) => {
|
||||
const sceneElement = API.getElement(element);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const points = getElementLineSegments(sceneElement, elementsMap).flat();
|
||||
|
||||
return [
|
||||
Math.min(...points.map((point) => point[0])),
|
||||
Math.min(...points.map((point) => point[1])),
|
||||
Math.max(...points.map((point) => point[0])),
|
||||
Math.max(...points.map((point) => point[1])),
|
||||
] as const;
|
||||
};
|
||||
|
||||
describe("box-selection", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
@@ -108,6 +124,497 @@ describe("box-selection", () => {
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select an element when the selection box only partially overlaps it", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect1]);
|
||||
|
||||
mouse.downAt(25, -20);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(75, 70);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lasso reselection", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => {
|
||||
const rectA = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const rectB = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 220,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "blue",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rectA, rectB]);
|
||||
mouse.select([rectA, rectB]);
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "lasso" });
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
mouse.downAt(110, 50);
|
||||
mouse.moveTo(50, -20);
|
||||
|
||||
expect(h.app.lassoTrail.hasCurrentTrail).toBe(true);
|
||||
|
||||
mouse.moveTo(-20, 50);
|
||||
mouse.moveTo(50, 120);
|
||||
mouse.moveTo(110, 50);
|
||||
mouse.up();
|
||||
});
|
||||
|
||||
assertSelectedElements([rectA.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("box-selection overlap mode", () => {
|
||||
const boxSelect = (
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
) => {
|
||||
mouse.downAt(startX, startY);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(endX, endY);
|
||||
mouse.up();
|
||||
};
|
||||
|
||||
const boxSelectTopLeftAabbCorner = (
|
||||
element: ReturnType<typeof API.createElement>,
|
||||
) => {
|
||||
const sceneElement = API.getElement(element);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const [x1, y1] = getElementBounds(sceneElement, elementsMap);
|
||||
|
||||
boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12);
|
||||
};
|
||||
|
||||
const boxSelectTopRightAabbCorner = (
|
||||
element: ReturnType<typeof API.createElement>,
|
||||
) => {
|
||||
const sceneElement = API.getElement(element);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const [, y1, x2] = getElementBounds(sceneElement, elementsMap);
|
||||
|
||||
boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12);
|
||||
};
|
||||
|
||||
const boxSelectTopLeftRotatedLocalBoundsCorner = (
|
||||
element: ReturnType<typeof API.createElement>,
|
||||
) => {
|
||||
const sceneElement = API.getElement(element);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true);
|
||||
const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const [cornerX, cornerY] = pointRotateRads(
|
||||
pointFrom(x1, y1),
|
||||
center,
|
||||
sceneElement.angle,
|
||||
);
|
||||
|
||||
boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { boxSelectionMode: "overlap" } }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should select an element when the selection box partially overlaps it", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect1]);
|
||||
|
||||
boxSelect(25, -20, 75, 70);
|
||||
|
||||
assertSelectedElements([rect1.id]);
|
||||
});
|
||||
|
||||
it("should not select a transparent rectangle when the selection box stays inside it", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect1]);
|
||||
|
||||
boxSelect(25, 25, 75, 75);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should select a transparent rectangle when the selection box crosses its outline", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect1]);
|
||||
|
||||
boxSelect(25, 25, 125, 75);
|
||||
|
||||
assertSelectedElements([rect1.id]);
|
||||
});
|
||||
|
||||
it("should not select a rotated transparent rectangle when the selection box stays inside it", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect1]);
|
||||
|
||||
boxSelect(40, 40, 60, 60);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 180,
|
||||
angle: Math.PI / 6,
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS },
|
||||
roughness: 0,
|
||||
});
|
||||
|
||||
API.setElements([rect]);
|
||||
|
||||
const sceneRect = API.getElement(rect);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds(
|
||||
sceneRect,
|
||||
elementsMap,
|
||||
);
|
||||
const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect);
|
||||
|
||||
expect(outlineX1).toBeGreaterThan(boundsX1 - 1);
|
||||
expect(outlineY1).toBeGreaterThan(boundsY1 - 1);
|
||||
expect(outlineX2).toBeLessThan(boundsX2 + 1);
|
||||
expect(outlineY2).toBeLessThan(boundsY2 + 1);
|
||||
|
||||
boxSelect(
|
||||
outlineX1 - (outlineX1 - boundsX1) / 2,
|
||||
outlineY1 - (outlineY1 - boundsY1) / 2,
|
||||
outlineX2 + (boundsX2 - outlineX2) / 2,
|
||||
outlineY2 + (boundsY2 - outlineY2) / 2,
|
||||
);
|
||||
|
||||
assertSelectedElements([rect.id]);
|
||||
});
|
||||
|
||||
it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect]);
|
||||
|
||||
boxSelectTopLeftAabbCorner(rect);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => {
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([ellipse]);
|
||||
|
||||
boxSelectTopRightAabbCorner(ellipse);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([diamond]);
|
||||
|
||||
boxSelectTopRightAabbCorner(diamond);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => {
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([ellipse]);
|
||||
|
||||
boxSelectTopLeftRotatedLocalBoundsCorner(ellipse);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([diamond]);
|
||||
|
||||
boxSelectTopLeftRotatedLocalBoundsCorner(diamond);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
text: "test",
|
||||
});
|
||||
|
||||
API.setElements([text]);
|
||||
|
||||
boxSelect(-18, -18, -8, -8);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => {
|
||||
const image = API.createElement({
|
||||
type: "image",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
fileId: "file_A",
|
||||
status: "saved",
|
||||
});
|
||||
|
||||
API.setElements([image]);
|
||||
|
||||
boxSelect(-18, -18, -8, -8);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect]);
|
||||
|
||||
mouse.clickAt(50, 50);
|
||||
assertSelectedElements([rect.id]);
|
||||
|
||||
const sceneRect = API.getElement(rect);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const [x1, y1] = getElementBounds(sceneRect, elementsMap);
|
||||
|
||||
mouse.clickAt(x1 + 2, y1 + 2);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a line when the selection box only overlaps its bounds", () => {
|
||||
const line = API.createElement({
|
||||
type: "line",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
|
||||
});
|
||||
|
||||
API.setElements([line]);
|
||||
|
||||
boxSelect(20, 50, 30, 60);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => {
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
angle: Math.PI / 4,
|
||||
backgroundColor: "transparent",
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
pointFrom<LocalPoint>(0, 100),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
],
|
||||
});
|
||||
|
||||
API.setElements([freedraw]);
|
||||
|
||||
const sceneFreedraw = API.getElement(freedraw);
|
||||
const elementsMap = h.scene.getNonDeletedElementsMap();
|
||||
const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap);
|
||||
|
||||
mouse.clickAt(x1 + 2, y1 + 2);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a freedraw when the selection box only overlaps its bounds", () => {
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(50, 50),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
});
|
||||
|
||||
API.setElements([freedraw]);
|
||||
|
||||
boxSelect(20, 50, 30, 60);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 80,
|
||||
frameId: frame.id,
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([frame, rect1]);
|
||||
|
||||
boxSelect(60, 20, 90, 60);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inner box-selection", () => {
|
||||
@@ -326,7 +833,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
@@ -359,7 +866,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
@@ -392,7 +899,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
@@ -438,7 +945,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
@@ -483,7 +990,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
@@ -558,3 +1065,58 @@ describe("selectedElementIds stability", () => {
|
||||
expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deselecting", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("esc unwinds nested group editing before deselecting", () => {
|
||||
const rectA = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const rectB = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 0,
|
||||
groupIds: ["outer"],
|
||||
});
|
||||
const rectC = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 200,
|
||||
y: 0,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
|
||||
API.setElements([rectA, rectB, rectC]);
|
||||
|
||||
mouse.select(rectA);
|
||||
assertSelectedElements(rectA, rectB, rectC);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
|
||||
mouse.doubleClickOn(rectA);
|
||||
assertSelectedElements(rectA, rectC);
|
||||
expect(h.state.editingGroupId).toBe("outer");
|
||||
|
||||
mouse.doubleClickOn(rectA);
|
||||
assertSelectedElements(rectA);
|
||||
expect(h.state.editingGroupId).toBe("inner");
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
assertSelectedElements(rectA, rectC);
|
||||
expect(h.state.editingGroupId).toBe("outer");
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
assertSelectedElements(rectA, rectB, rectC);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({ outer: true });
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
expect(API.getSelectedElements()).toEqual([]);
|
||||
expect(h.state.editingGroupId).toBeNull();
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -449,29 +449,28 @@ const stripProps = (
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
export const checkpointHistory = (history: History, name: string) => {
|
||||
expect(
|
||||
history.undoStack.map((x) => ({
|
||||
...x,
|
||||
elements: {
|
||||
...x.elements,
|
||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] undo stack`);
|
||||
const normalizeHistoryEntry = (entry: History["undoStack"][number]) => {
|
||||
const { markers, ...rest } = entry;
|
||||
|
||||
expect(
|
||||
history.redoStack.map((x) => ({
|
||||
...x,
|
||||
return {
|
||||
...rest,
|
||||
...(markers ? { markers } : {}),
|
||||
elements: {
|
||||
...x.elements,
|
||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
||||
...entry.elements,
|
||||
added: stripProps(entry.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(entry.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(entry.elements.updated, ["seed", "versionNonce"]),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] redo stack`);
|
||||
};
|
||||
};
|
||||
|
||||
expect(history.undoStack.map(normalizeHistoryEntry)).toMatchSnapshot(
|
||||
`[${name}] undo stack`,
|
||||
);
|
||||
|
||||
expect(history.redoStack.map(normalizeHistoryEntry)).toMatchSnapshot(
|
||||
`[${name}] redo stack`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common";
|
||||
import {
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type GlobalPoint,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { EditorInterface } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||
|
||||
const TEXT_AUTO_RESIZE_HANDLE_GAP = 12;
|
||||
const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16;
|
||||
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10;
|
||||
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT =
|
||||
TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2;
|
||||
const MAX_HANDLE_HEIGHT_RATIO = 0.8;
|
||||
|
||||
export const getTextBoxPadding = (zoomValue: number) =>
|
||||
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue;
|
||||
|
||||
export const getTextAutoResizeHandle = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
zoomValue: number,
|
||||
formFactor: EditorInterface["formFactor"],
|
||||
) => {
|
||||
if (
|
||||
formFactor !== "desktop" ||
|
||||
TEXT_AUTO_RESIZE_HANDLE_LENGTH >
|
||||
textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const padding = getTextBoxPadding(zoomValue);
|
||||
const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue;
|
||||
const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue;
|
||||
const center = pointFrom(
|
||||
textElement.x + textElement.width / 2,
|
||||
textElement.y + textElement.height / 2,
|
||||
);
|
||||
const handleCenter = pointRotateRads(
|
||||
pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]),
|
||||
center,
|
||||
textElement.angle,
|
||||
);
|
||||
|
||||
return {
|
||||
center: handleCenter,
|
||||
start: pointRotateRads(
|
||||
pointFrom(handleCenter[0], handleCenter[1] - length / 2),
|
||||
handleCenter,
|
||||
textElement.angle,
|
||||
) as GlobalPoint,
|
||||
end: pointRotateRads(
|
||||
pointFrom(handleCenter[0], handleCenter[1] + length / 2),
|
||||
handleCenter,
|
||||
textElement.angle,
|
||||
) as GlobalPoint,
|
||||
hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue,
|
||||
hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const isPointHittingTextAutoResizeHandle = (
|
||||
point: Readonly<{ x: number; y: number }>,
|
||||
textElement: ExcalidrawTextElement,
|
||||
zoomValue: number,
|
||||
formFactor: EditorInterface["formFactor"],
|
||||
) => {
|
||||
const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor);
|
||||
|
||||
if (!handle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unrotatedPoint = pointRotateRads(
|
||||
pointFrom(point.x, point.y),
|
||||
handle.center,
|
||||
-textElement.angle as Radians,
|
||||
);
|
||||
|
||||
return (
|
||||
Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 &&
|
||||
Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import { type StoreDelta } from "@excalidraw/element";
|
||||
|
||||
import type { Delta } from "@excalidraw/element";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
ElementChange,
|
||||
ElementPropName,
|
||||
TouchedElementProps,
|
||||
} from "./types";
|
||||
|
||||
const LEDGER_IGNORED_PROPS = new Set([
|
||||
"version",
|
||||
"versionNonce",
|
||||
"seed",
|
||||
"updated",
|
||||
"index",
|
||||
]);
|
||||
|
||||
export const TX_UNDO_OVERRIDE_IGNORED_PROPS = new Set([
|
||||
"version",
|
||||
"versionNonce",
|
||||
"isDeleted",
|
||||
]);
|
||||
|
||||
type ElementRecord = Record<string, unknown>;
|
||||
export type ElementUpdatedProps = Omit<
|
||||
Partial<OrderedExcalidrawElement>,
|
||||
"id" | "updated" | "seed"
|
||||
>;
|
||||
export type ElementUpdatedPropName = Extract<keyof ElementUpdatedProps, string>;
|
||||
type ElementPropValueMap = ElementUpdatedProps;
|
||||
|
||||
export type ElementUpdatedEntry = Delta<ElementPropValueMap>;
|
||||
export type ElementUpdatedEntryMap = Record<string, ElementUpdatedEntry>;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
export const getElementProp = <TProp extends ElementPropName>(
|
||||
element: ExcalidrawElement,
|
||||
prop: TProp,
|
||||
): ExcalidrawElement[TProp] =>
|
||||
(element as ElementRecord)[prop] as ExcalidrawElement[TProp];
|
||||
|
||||
export const setOrderedElementProp = <TProp extends ElementPropName>(
|
||||
element: Mutable<OrderedExcalidrawElement>,
|
||||
prop: TProp,
|
||||
value: OrderedExcalidrawElement[TProp],
|
||||
) => {
|
||||
(element as ElementRecord)[prop] = value;
|
||||
};
|
||||
|
||||
/** Deep equality used by ledger conflict/touched-prop detection. */
|
||||
export const isLedgerValueEqual = (left: unknown, right: unknown): boolean => {
|
||||
if (Object.is(left, right)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (!isLedgerValueEqual(left[index], right[index])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPlainObject(left) && isPlainObject(right)) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of leftKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(right, key)) {
|
||||
return false;
|
||||
}
|
||||
if (!isLedgerValueEqual(left[key], right[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Shallow-copies a scene map. Entries share references with the original. */
|
||||
export const shallowCopySceneElements = (
|
||||
elements: ReadonlyMap<string, ExcalidrawElement>,
|
||||
): SceneElementsMap => new Map(elements) as SceneElementsMap;
|
||||
|
||||
export const createAllTouchedElementProps = (): TouchedElementProps => ({
|
||||
kind: "all",
|
||||
});
|
||||
|
||||
export const createPartialTouchedElementProps = (
|
||||
props: Iterable<ElementPropName> = [],
|
||||
): TouchedElementProps => ({
|
||||
kind: "partial",
|
||||
props: new Set(props),
|
||||
});
|
||||
|
||||
export const hasTouchedProps = (touchedProps: TouchedElementProps): boolean =>
|
||||
touchedProps.kind === "all" || touchedProps.props.size > 0;
|
||||
|
||||
export const touchesWholeElement = (
|
||||
touchedProps: TouchedElementProps,
|
||||
): boolean => touchedProps.kind === "all";
|
||||
|
||||
export const isPartialTouchedProps = (
|
||||
touchedProps: TouchedElementProps,
|
||||
): touchedProps is Extract<TouchedElementProps, { kind: "partial" }> =>
|
||||
touchedProps.kind === "partial";
|
||||
|
||||
export const hasTouchedProp = (
|
||||
touchedProps: TouchedElementProps,
|
||||
prop: ElementPropName,
|
||||
): boolean => touchedProps.kind === "all" || touchedProps.props.has(prop);
|
||||
|
||||
export const mergeTouchedProps = (
|
||||
left: TouchedElementProps,
|
||||
right: TouchedElementProps,
|
||||
): TouchedElementProps => {
|
||||
if (left.kind === "all" || right.kind === "all") {
|
||||
return createAllTouchedElementProps();
|
||||
}
|
||||
|
||||
return createPartialTouchedElementProps([...left.props, ...right.props]);
|
||||
};
|
||||
|
||||
/** Returns changed property names between two element snapshots. */
|
||||
export const collectTouchedProps = (
|
||||
before: ExcalidrawElement | null,
|
||||
after: ExcalidrawElement | null,
|
||||
): TouchedElementProps => {
|
||||
if (!before || !after) {
|
||||
return createAllTouchedElementProps();
|
||||
}
|
||||
|
||||
const touchedProps = new Set<ElementPropName>();
|
||||
const keys = new Set<ElementPropName>([
|
||||
...(Object.keys(before) as ElementPropName[]),
|
||||
...(Object.keys(after) as ElementPropName[]),
|
||||
]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (LEDGER_IGNORED_PROPS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!isLedgerValueEqual(
|
||||
getElementProp(before, key),
|
||||
getElementProp(after, key),
|
||||
)
|
||||
) {
|
||||
touchedProps.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return createPartialTouchedElementProps(touchedProps);
|
||||
};
|
||||
|
||||
/** Returns ids whose element snapshot changed between two points in time. */
|
||||
export const collectChangedElementIds = (
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
) => collectElementChanges(before, after).map((change) => change.id);
|
||||
|
||||
export const collectElementChanges = (
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
): ElementChange[] => {
|
||||
const changes: ElementChange[] = [];
|
||||
const candidateIds = new Set<string>([...before.keys(), ...after.keys()]);
|
||||
|
||||
for (const id of candidateIds) {
|
||||
const beforeElement = before.get(id) ?? null;
|
||||
const afterElement = after.get(id) ?? null;
|
||||
const touchedProps = collectTouchedProps(beforeElement, afterElement);
|
||||
if (!hasTouchedProps(touchedProps)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
id,
|
||||
before: beforeElement,
|
||||
after: afterElement,
|
||||
touchedProps,
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export const serializeConsumedPropKey = (
|
||||
elementId: string,
|
||||
prop: ElementPropName,
|
||||
) => `${elementId}\u0000${prop}`;
|
||||
|
||||
export const getUpdatedElementEntries = (delta: StoreDelta) =>
|
||||
delta.elements.updated as ElementUpdatedEntryMap;
|
||||
|
||||
export const getElementPropEntries = (props: ElementPropValueMap) =>
|
||||
Object.entries(props) as [
|
||||
ElementUpdatedPropName,
|
||||
ElementUpdatedProps[ElementUpdatedPropName],
|
||||
][];
|
||||
|
||||
export const hasUpdatedElementEntries = (delta: StoreDelta) =>
|
||||
Object.keys(getUpdatedElementEntries(delta)).length > 0;
|
||||
|
||||
export const serializeIntermediateValue = (value: unknown): string => {
|
||||
const serialize = (input: unknown, seen: WeakSet<object>): string => {
|
||||
if (input === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
switch (typeof input) {
|
||||
case "undefined":
|
||||
return "undefined";
|
||||
case "boolean":
|
||||
return input ? "boolean:true" : "boolean:false";
|
||||
case "number":
|
||||
if (Number.isNaN(input)) {
|
||||
return "number:NaN";
|
||||
}
|
||||
if (Object.is(input, -0)) {
|
||||
return "number:-0";
|
||||
}
|
||||
return `number:${input}`;
|
||||
case "bigint":
|
||||
return `bigint:${input.toString()}`;
|
||||
case "string":
|
||||
return `string:${JSON.stringify(input)}`;
|
||||
case "symbol":
|
||||
return `symbol:${String(input)}`;
|
||||
case "function":
|
||||
return `function:${input.name}`;
|
||||
case "object":
|
||||
break;
|
||||
default:
|
||||
return `unknown:${String(input)}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
if (seen.has(input)) {
|
||||
return "[CircularArray]";
|
||||
}
|
||||
seen.add(input);
|
||||
const serialized = `[${input
|
||||
.map((item) => serialize(item, seen))
|
||||
.join(",")}]`;
|
||||
seen.delete(input);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
if (isPlainObject(input)) {
|
||||
if (seen.has(input)) {
|
||||
return "{CircularObject}";
|
||||
}
|
||||
seen.add(input);
|
||||
const serialized = `{${Object.keys(input)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${serialize(input[key], seen)}`)
|
||||
.join(",")}}`;
|
||||
seen.delete(input);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
try {
|
||||
return `object:${JSON.stringify(input)}`;
|
||||
} catch {
|
||||
return `object:${Object.prototype.toString.call(input)}`;
|
||||
}
|
||||
};
|
||||
|
||||
return serialize(value, new WeakSet<object>());
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export { collectChangedElementIds } from "./diff";
|
||||
export { TransactionLedger } from "./ledger";
|
||||
export { Transaction } from "./transaction";
|
||||
export { TransactionManager } from "./manager";
|
||||
|
||||
export type {
|
||||
AppStateResolver,
|
||||
AppStateResolverContext,
|
||||
TransactionElementUpdate,
|
||||
TransactionLedgerEntry,
|
||||
TransactionStatus,
|
||||
TransactionSummary,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,333 @@
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
collectElementChanges,
|
||||
collectTouchedProps,
|
||||
getElementProp,
|
||||
hasTouchedProps,
|
||||
isLedgerValueEqual,
|
||||
mergeTouchedProps,
|
||||
setOrderedElementProp,
|
||||
shallowCopySceneElements,
|
||||
touchesWholeElement,
|
||||
} from "./diff";
|
||||
|
||||
import type { TransactionLedgerEntry } from "./types";
|
||||
|
||||
/**
|
||||
* Keeps transaction-level scene mutations and materializes synthetic snapshots
|
||||
* for a single durable history commit.
|
||||
*/
|
||||
export class TransactionLedger {
|
||||
private readonly entries = new Map<string, TransactionLedgerEntry>();
|
||||
|
||||
/** Whether the transaction has any net element mutations. */
|
||||
hasEntries() {
|
||||
return this.entries.size > 0;
|
||||
}
|
||||
|
||||
/** Returns the ledger entry for an element, if any. */
|
||||
getEntry(elementId: string): TransactionLedgerEntry | undefined {
|
||||
return this.entries.get(elementId);
|
||||
}
|
||||
|
||||
/** Releases all ledger entries. */
|
||||
clear() {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Records one element mutation step into the ledger. */
|
||||
recordStep(
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
) {
|
||||
for (const change of collectElementChanges(before, after)) {
|
||||
const {
|
||||
id: elementId,
|
||||
before: beforeElement,
|
||||
after: afterElement,
|
||||
touchedProps,
|
||||
} = change;
|
||||
|
||||
const existing = this.entries.get(elementId);
|
||||
if (!existing) {
|
||||
this.entries.set(elementId, {
|
||||
baselineElement: beforeElement
|
||||
? deepCopyElement(beforeElement)
|
||||
: null,
|
||||
targetElement: afterElement ? deepCopyElement(afterElement) : null,
|
||||
touchedProps,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.targetElement = afterElement
|
||||
? deepCopyElement(afterElement)
|
||||
: null;
|
||||
existing.touchedProps = mergeTouchedProps(
|
||||
existing.touchedProps,
|
||||
touchedProps,
|
||||
);
|
||||
|
||||
// Created then deleted inside one transaction leaves no durable footprint.
|
||||
if (!existing.baselineElement && !existing.targetElement) {
|
||||
this.entries.delete(elementId);
|
||||
continue;
|
||||
}
|
||||
if (!existing.baselineElement && existing.targetElement?.isDeleted) {
|
||||
this.entries.delete(elementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds synthetic element before/after snapshots with a fixed
|
||||
* "live-wins-per-prop" strategy.
|
||||
*/
|
||||
buildSyntheticSnapshots(live: ReadonlyMap<string, ExcalidrawElement>) {
|
||||
// Shallow copy — untouched elements stay as live references.
|
||||
// Only elements mutated in-place (prop-level updates) are deep-copied below.
|
||||
const elementsBefore = shallowCopySceneElements(live);
|
||||
const elementsAfter = shallowCopySceneElements(live);
|
||||
|
||||
for (const [elementId, entry] of this.entries) {
|
||||
this.reconcileEntrySnapshots(
|
||||
elementId,
|
||||
entry,
|
||||
live,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
);
|
||||
}
|
||||
|
||||
return { elementsBefore, elementsAfter };
|
||||
}
|
||||
|
||||
private reconcileEntrySnapshots(
|
||||
elementId: string,
|
||||
entry: TransactionLedgerEntry,
|
||||
live: ReadonlyMap<string, ExcalidrawElement>,
|
||||
elementsBefore: SceneElementsMap,
|
||||
elementsAfter: SceneElementsMap,
|
||||
) {
|
||||
if (!entry.baselineElement) {
|
||||
this.applyCreatedElementSnapshots(
|
||||
elementId,
|
||||
entry.targetElement,
|
||||
live,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.targetElement) {
|
||||
this.applyDeletedElementSnapshots(
|
||||
elementId,
|
||||
entry.baselineElement,
|
||||
live,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyUpdatedElementSnapshots(
|
||||
elementId,
|
||||
entry,
|
||||
live,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
);
|
||||
}
|
||||
|
||||
private applyCreatedElementSnapshots(
|
||||
elementId: string,
|
||||
targetElement: ExcalidrawElement | null,
|
||||
live: ReadonlyMap<string, ExcalidrawElement>,
|
||||
elementsBefore: SceneElementsMap,
|
||||
elementsAfter: SceneElementsMap,
|
||||
) {
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveElement = live.get(elementId) ?? null;
|
||||
if (
|
||||
!liveElement ||
|
||||
liveElement.isDeleted ||
|
||||
hasTouchedProps(collectTouchedProps(targetElement, liveElement))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
elementsBefore.delete(elementId);
|
||||
elementsAfter.set(
|
||||
elementId,
|
||||
deepCopyElement(targetElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
}
|
||||
|
||||
private applyDeletedElementSnapshots(
|
||||
elementId: string,
|
||||
baselineElement: ExcalidrawElement,
|
||||
live: ReadonlyMap<string, ExcalidrawElement>,
|
||||
elementsBefore: SceneElementsMap,
|
||||
elementsAfter: SceneElementsMap,
|
||||
) {
|
||||
const liveElement = live.get(elementId) ?? null;
|
||||
if (liveElement && !liveElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
elementsBefore.set(
|
||||
elementId,
|
||||
deepCopyElement(baselineElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
elementsAfter.delete(elementId);
|
||||
}
|
||||
|
||||
private applyUpdatedElementSnapshots(
|
||||
elementId: string,
|
||||
entry: TransactionLedgerEntry,
|
||||
live: ReadonlyMap<string, ExcalidrawElement>,
|
||||
elementsBefore: SceneElementsMap,
|
||||
elementsAfter: SceneElementsMap,
|
||||
) {
|
||||
const liveElement = live.get(elementId) ?? null;
|
||||
const targetElement = entry.targetElement;
|
||||
const baselineElement = entry.baselineElement;
|
||||
const beforeElement = elementsBefore.get(elementId);
|
||||
const afterElement = elementsAfter.get(elementId);
|
||||
|
||||
if (
|
||||
!liveElement ||
|
||||
!baselineElement ||
|
||||
!targetElement ||
|
||||
!beforeElement ||
|
||||
!afterElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (touchesWholeElement(entry.touchedProps)) {
|
||||
this.applyWholeElementSnapshots(
|
||||
elementId,
|
||||
baselineElement,
|
||||
targetElement,
|
||||
liveElement,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyPerPropSnapshots({
|
||||
entry,
|
||||
liveElement,
|
||||
baselineElement,
|
||||
targetElement,
|
||||
beforeElement,
|
||||
afterElement,
|
||||
elementId,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
});
|
||||
}
|
||||
|
||||
private applyWholeElementSnapshots(
|
||||
elementId: string,
|
||||
baselineElement: ExcalidrawElement,
|
||||
targetElement: ExcalidrawElement,
|
||||
liveElement: ExcalidrawElement,
|
||||
elementsBefore: SceneElementsMap,
|
||||
elementsAfter: SceneElementsMap,
|
||||
) {
|
||||
const hasLiveConflict = hasTouchedProps(
|
||||
collectTouchedProps(targetElement, liveElement),
|
||||
);
|
||||
if (hasLiveConflict) {
|
||||
return;
|
||||
}
|
||||
|
||||
elementsBefore.set(
|
||||
elementId,
|
||||
deepCopyElement(baselineElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
elementsAfter.set(
|
||||
elementId,
|
||||
deepCopyElement(targetElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
}
|
||||
|
||||
private applyPerPropSnapshots(args: {
|
||||
entry: TransactionLedgerEntry;
|
||||
liveElement: ExcalidrawElement;
|
||||
baselineElement: ExcalidrawElement;
|
||||
targetElement: ExcalidrawElement;
|
||||
beforeElement: ExcalidrawElement;
|
||||
afterElement: ExcalidrawElement;
|
||||
elementId: string;
|
||||
elementsBefore: SceneElementsMap;
|
||||
elementsAfter: SceneElementsMap;
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
liveElement,
|
||||
baselineElement,
|
||||
targetElement,
|
||||
beforeElement,
|
||||
afterElement,
|
||||
elementId,
|
||||
elementsBefore,
|
||||
elementsAfter,
|
||||
} = args;
|
||||
|
||||
// Deep-copy before mutating so we never touch live elements.
|
||||
const mutableBefore = deepCopyElement(
|
||||
beforeElement,
|
||||
) as Mutable<OrderedExcalidrawElement>;
|
||||
const mutableAfter = deepCopyElement(
|
||||
afterElement,
|
||||
) as Mutable<OrderedExcalidrawElement>;
|
||||
elementsBefore.set(elementId, mutableBefore as OrderedExcalidrawElement);
|
||||
elementsAfter.set(elementId, mutableAfter as OrderedExcalidrawElement);
|
||||
|
||||
if (entry.touchedProps.kind !== "partial") {
|
||||
return;
|
||||
}
|
||||
|
||||
let appliedProps = 0;
|
||||
for (const prop of entry.touchedProps.props) {
|
||||
const liveValue = getElementProp(liveElement, prop);
|
||||
const targetValue = getElementProp(targetElement, prop);
|
||||
if (!isLedgerValueEqual(liveValue, targetValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setOrderedElementProp(
|
||||
mutableBefore,
|
||||
prop,
|
||||
getElementProp(baselineElement, prop),
|
||||
);
|
||||
setOrderedElementProp(mutableAfter, prop, targetValue);
|
||||
appliedProps += 1;
|
||||
}
|
||||
|
||||
if (appliedProps === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutableBefore.version = baselineElement.version;
|
||||
mutableBefore.versionNonce = baselineElement.versionNonce;
|
||||
mutableAfter.version = targetElement.version;
|
||||
mutableAfter.versionNonce = targetElement.versionNonce;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
Delta,
|
||||
ElementsDelta,
|
||||
mergeStoreDeltaMarkers,
|
||||
type StoreDelta,
|
||||
type TxUndoOverride,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
HistoryDelta,
|
||||
type HistoryBeforeRecordListener,
|
||||
type HistoryEffectiveDeltaResolverContext,
|
||||
} from "../history";
|
||||
|
||||
import {
|
||||
type ElementUpdatedPropName,
|
||||
getUpdatedElementEntries,
|
||||
hasUpdatedElementEntries,
|
||||
isLedgerValueEqual,
|
||||
type ElementUpdatedProps,
|
||||
type ElementUpdatedEntryMap,
|
||||
} from "./diff";
|
||||
import { Transaction } from "./transaction";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
import type { TransactionStatus } from "./types";
|
||||
|
||||
type TransactionRecord = {
|
||||
tx: Transaction | null;
|
||||
phase: TransactionStatus;
|
||||
};
|
||||
type TransactionHistoryBridge = {
|
||||
onBeforeRecord: (callback: HistoryBeforeRecordListener) => () => void;
|
||||
setEffectiveDeltaResolver: (
|
||||
resolver:
|
||||
| ((
|
||||
delta: HistoryDelta,
|
||||
context: HistoryEffectiveDeltaResolverContext,
|
||||
) => HistoryDelta)
|
||||
| null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type MutableElementUpdatedProps = Mutable<ElementUpdatedProps>;
|
||||
|
||||
const setElementUpdatedOverride = (
|
||||
overrides: MutableElementUpdatedProps,
|
||||
prop: ElementUpdatedPropName,
|
||||
value: unknown,
|
||||
) => {
|
||||
(overrides as Record<ElementUpdatedPropName, unknown>)[prop] = value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin factory that holds the app reference and creates Transaction instances.
|
||||
*/
|
||||
export class TransactionManager {
|
||||
private readonly app: AppClassProperties;
|
||||
/**
|
||||
* Single authoritative lifecycle registry for transactions.
|
||||
*
|
||||
* We retain ended/canceled metadata after the tx object is released because
|
||||
* history markers only persist `txId`; undo/redo still needs to resolve
|
||||
* whether a tx was active or already ended when applying effective deltas.
|
||||
*/
|
||||
private readonly transactionRecords = new Map<string, TransactionRecord>();
|
||||
/**
|
||||
* Active transaction ids ordered by most-recent registration first.
|
||||
* This preserves deterministic priority when multiple active txs overlap on
|
||||
* the same element+prop and compete to reserve override markers.
|
||||
*/
|
||||
private readonly activeTransactionIdsByPriority: string[] = [];
|
||||
private detachBeforeRecordHook: (() => void) | null = null;
|
||||
|
||||
constructor(app: AppClassProperties) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds transaction bookkeeping to history lifecycle hooks.
|
||||
* Call once during app initialization.
|
||||
*/
|
||||
attachHistory(history: TransactionHistoryBridge) {
|
||||
this.detachBeforeRecordHook?.();
|
||||
history.setEffectiveDeltaResolver((delta, context) =>
|
||||
this.resolveEffectiveDelta(delta, context),
|
||||
);
|
||||
this.detachBeforeRecordHook = history.onBeforeRecord((delta) =>
|
||||
this.onDurableIncrement(delta),
|
||||
);
|
||||
}
|
||||
|
||||
private removeActiveTransactionId(txId: string) {
|
||||
const txIndex = this.activeTransactionIdsByPriority.indexOf(txId);
|
||||
if (txIndex >= 0) {
|
||||
this.activeTransactionIdsByPriority.splice(txIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private getRequiredTransactionRecord(txId: string): TransactionRecord {
|
||||
const record = this.transactionRecords.get(txId);
|
||||
if (!record) {
|
||||
throw new Error(`Unknown transaction: ${txId}`);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
registerTransaction(tx: Transaction) {
|
||||
this.transactionRecords.set(tx.id, {
|
||||
tx,
|
||||
phase: "active",
|
||||
});
|
||||
this.activeTransactionIdsByPriority.unshift(tx.id);
|
||||
}
|
||||
|
||||
detachTransactionInstance(txId: string) {
|
||||
const record = this.getRequiredTransactionRecord(txId);
|
||||
record.tx = null;
|
||||
this.removeActiveTransactionId(txId);
|
||||
}
|
||||
|
||||
getStatus(txId: string): TransactionStatus {
|
||||
return this.getRequiredTransactionRecord(txId).phase;
|
||||
}
|
||||
|
||||
private markTransactionFinished(
|
||||
txId: string,
|
||||
phase: Exclude<TransactionStatus, "active">,
|
||||
): TransactionStatus {
|
||||
const record = this.getRequiredTransactionRecord(txId);
|
||||
if (record.phase !== "active") {
|
||||
return record.phase;
|
||||
}
|
||||
|
||||
record.phase = phase;
|
||||
this.removeActiveTransactionId(txId);
|
||||
return record.phase;
|
||||
}
|
||||
|
||||
markTransactionCommitted(txId: string): TransactionStatus {
|
||||
return this.markTransactionFinished(txId, "committed");
|
||||
}
|
||||
|
||||
markTransactionCanceled(txId: string): TransactionStatus {
|
||||
return this.markTransactionFinished(txId, "canceled");
|
||||
}
|
||||
|
||||
onDurableIncrement(delta: StoreDelta) {
|
||||
if (this.activeTransactionIdsByPriority.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!hasUpdatedElementEntries(delta)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const txUndoOverrides = this.collectUndoOverrides(delta);
|
||||
if (txUndoOverrides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeStoreDeltaMarkers(delta, { txUndoOverrides });
|
||||
}
|
||||
|
||||
private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] {
|
||||
const overrides: TxUndoOverride[] = [];
|
||||
const reservedConsumedKeys = new Set<string>();
|
||||
|
||||
for (const txId of this.activeTransactionIdsByPriority) {
|
||||
const record = this.transactionRecords.get(txId);
|
||||
if (!record || record.phase !== "active" || !record.tx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const txOverrides = record.tx.collectUndoOverridesForDelta(
|
||||
delta,
|
||||
reservedConsumedKeys,
|
||||
);
|
||||
|
||||
for (const override of txOverrides) {
|
||||
if (reservedConsumedKeys.has(override.consumedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
reservedConsumedKeys.add(override.consumedKey);
|
||||
overrides.push(override);
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
private resolveEffectiveDelta(
|
||||
delta: HistoryDelta,
|
||||
_context: HistoryEffectiveDeltaResolverContext,
|
||||
): HistoryDelta {
|
||||
const txUndoOverrides = delta.markers?.txUndoOverrides;
|
||||
if (!txUndoOverrides || txUndoOverrides.length === 0) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
const updatedEntries = getUpdatedElementEntries(delta);
|
||||
const insertedOverridesByElement = new Map<
|
||||
string,
|
||||
MutableElementUpdatedProps
|
||||
>();
|
||||
|
||||
for (const override of txUndoOverrides) {
|
||||
if (!this.shouldApplyUndoOverride(override.txId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentEntry = updatedEntries[override.elementId];
|
||||
if (!currentEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prop = override.prop as ElementUpdatedPropName;
|
||||
const currentInsertedValue = currentEntry.inserted[prop];
|
||||
if (
|
||||
!isLedgerValueEqual(
|
||||
currentInsertedValue,
|
||||
override.expectedInsertedValue,
|
||||
)
|
||||
) {
|
||||
// Guard against over-applying once the delta has already evolved.
|
||||
continue;
|
||||
}
|
||||
|
||||
const elementOverrides = insertedOverridesByElement.get(
|
||||
override.elementId,
|
||||
);
|
||||
if (elementOverrides) {
|
||||
setElementUpdatedOverride(
|
||||
elementOverrides,
|
||||
prop,
|
||||
override.preTxBaselineValue,
|
||||
);
|
||||
} else {
|
||||
const nextOverrides: MutableElementUpdatedProps = {};
|
||||
setElementUpdatedOverride(
|
||||
nextOverrides,
|
||||
prop,
|
||||
override.preTxBaselineValue,
|
||||
);
|
||||
insertedOverridesByElement.set(override.elementId, nextOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
if (insertedOverridesByElement.size === 0) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
const nextUpdatedEntries: ElementUpdatedEntryMap = {
|
||||
...updatedEntries,
|
||||
};
|
||||
for (const [elementId, insertedOverrides] of insertedOverridesByElement) {
|
||||
const currentEntry = updatedEntries[elementId];
|
||||
if (!currentEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextUpdatedEntries[elementId] = Delta.create(
|
||||
{ ...currentEntry.deleted },
|
||||
{ ...currentEntry.inserted, ...insertedOverrides },
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveElements = ElementsDelta.create(
|
||||
delta.elements.added,
|
||||
delta.elements.removed,
|
||||
nextUpdatedEntries,
|
||||
);
|
||||
|
||||
return HistoryDelta.create(effectiveElements, delta.appState, {
|
||||
id: delta.id,
|
||||
markers: delta.markers,
|
||||
}) as HistoryDelta;
|
||||
}
|
||||
|
||||
private shouldApplyUndoOverride(txId: string): boolean {
|
||||
const record = this.transactionRecords.get(txId);
|
||||
return !!record && record.phase !== "active";
|
||||
}
|
||||
|
||||
create(): Transaction {
|
||||
return new Transaction(this.app, this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { randomId } from "@excalidraw/common";
|
||||
import {
|
||||
CaptureUpdateAction,
|
||||
newElementWith,
|
||||
type ElementUpdate,
|
||||
type StoreDelta,
|
||||
type TxUndoOverride,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { shallowCopySceneElements } from "./diff";
|
||||
import { TransactionLedger } from "./ledger";
|
||||
|
||||
import {
|
||||
type AppStateResolver,
|
||||
type AppStateResolverContext,
|
||||
type TransactionElementOfType,
|
||||
type TransactionElementUpdate,
|
||||
type TransactionStatus,
|
||||
type TransactionSummary,
|
||||
} from "./types";
|
||||
import { TxUndoOverridePlanner } from "./undoOverridePlanner";
|
||||
|
||||
import type { TransactionManager } from "./manager";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ObservedAppState,
|
||||
SceneData,
|
||||
} from "../types";
|
||||
|
||||
type CommitOptions = {
|
||||
resolveAppState?: AppStateResolver;
|
||||
};
|
||||
|
||||
/**
|
||||
* A transaction that records mutations via `updateScene(NEVER)` and commits
|
||||
* a single synthetic durable history entry at the end.
|
||||
*/
|
||||
export class Transaction {
|
||||
public readonly id = `tx-${randomId()}`;
|
||||
|
||||
private readonly app: AppClassProperties;
|
||||
private readonly manager: TransactionManager;
|
||||
private readonly ledger = new TransactionLedger();
|
||||
private readonly undoOverridePlanner = new TxUndoOverridePlanner();
|
||||
private readonly initialAppState: Partial<ObservedAppState>;
|
||||
|
||||
private accumulatedAppState: Record<string, unknown> = {};
|
||||
private cachedSummary: TransactionSummary | null = null;
|
||||
|
||||
constructor(app: AppClassProperties, manager: TransactionManager) {
|
||||
this.app = app;
|
||||
this.manager = manager;
|
||||
this.initialAppState = { ...app.store.snapshot.appState };
|
||||
this.manager.registerTransaction(this);
|
||||
}
|
||||
|
||||
get status(): TransactionStatus {
|
||||
return this.manager.getStatus(this.id);
|
||||
}
|
||||
|
||||
private assertActive(action: string): void {
|
||||
const status = this.status;
|
||||
if (status !== "active") {
|
||||
throw new Error(
|
||||
`Cannot ${action} — transaction ${this.id} is already ${status}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private closeTransaction() {
|
||||
this.manager.detachTransactionInstance(this.id);
|
||||
this.undoOverridePlanner.clear();
|
||||
}
|
||||
|
||||
public collectUndoOverridesForDelta(
|
||||
delta: StoreDelta,
|
||||
reservedConsumedKeys: Set<string>,
|
||||
): TxUndoOverride[] {
|
||||
if (this.status !== "active") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates =
|
||||
this.undoOverridePlanner.collectCandidatesForDurableDelta(
|
||||
delta,
|
||||
(elementId) => this.ledger.getEntry(elementId),
|
||||
reservedConsumedKeys,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const overrides: TxUndoOverride[] = [];
|
||||
for (const candidate of candidates) {
|
||||
this.undoOverridePlanner.markConsumed(candidate.consumedKey);
|
||||
overrides.push({
|
||||
txId: this.id,
|
||||
...candidate,
|
||||
});
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
updateScene<K extends keyof AppState>(data: {
|
||||
elements?: SceneData["elements"];
|
||||
appState?: Pick<AppState, K> | null;
|
||||
}): void {
|
||||
this.assertActive("updateScene");
|
||||
|
||||
// Snapshot before (shallow copy — replaceAllElements mutates the map in-place)
|
||||
const before = shallowCopySceneElements(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
);
|
||||
|
||||
// Apply through the real updateScene with NEVER.
|
||||
this.app.api.updateScene({
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// Snapshot after
|
||||
const after = this.app.scene.getElementsMapIncludingDeleted();
|
||||
|
||||
this.undoOverridePlanner.recordStep(before, after);
|
||||
|
||||
// Record element diff into ledger
|
||||
this.ledger.recordStep(before, after);
|
||||
|
||||
// Accumulate appState intent
|
||||
if (data.appState) {
|
||||
this.accumulatedAppState = {
|
||||
...this.accumulatedAppState,
|
||||
...(data.appState as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial element updates convenience API.
|
||||
*
|
||||
* Example:
|
||||
* tx.updateElements({
|
||||
* elements: [
|
||||
* { id: "a", type: "rectangle", updates: { strokeColor: "#f00" } },
|
||||
* { id: "b", type: "rectangle", updates: { x: 10, y: 20 } },
|
||||
* ],
|
||||
* })
|
||||
*/
|
||||
updateElements<K extends keyof AppState>(data: {
|
||||
elements: readonly TransactionElementUpdate[];
|
||||
appState?: Pick<AppState, K> | null;
|
||||
}): void {
|
||||
const updatesById = new Map<string, TransactionElementUpdate>();
|
||||
|
||||
for (const update of data.elements) {
|
||||
updatesById.set(update.id, update);
|
||||
}
|
||||
|
||||
if (updatesById.size === 0) {
|
||||
this.updateScene({ appState: data.appState });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextElements = this.app.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
const update = updatesById.get(element.id);
|
||||
if (!update) {
|
||||
return element;
|
||||
}
|
||||
if (element.type !== update.type) {
|
||||
throw new Error(
|
||||
`Cannot apply tx.updateElements update for "${update.id}": expected "${update.type}", got "${element.type}".`,
|
||||
);
|
||||
}
|
||||
|
||||
type MatchingElement = TransactionElementOfType<typeof update.type>;
|
||||
return newElementWith(
|
||||
element as MatchingElement,
|
||||
update.updates as ElementUpdate<MatchingElement>,
|
||||
);
|
||||
});
|
||||
|
||||
this.updateScene({
|
||||
elements: nextElements,
|
||||
appState: data.appState,
|
||||
});
|
||||
}
|
||||
|
||||
commit(options?: CommitOptions): TransactionSummary {
|
||||
if (this.cachedSummary) {
|
||||
return this.cachedSummary;
|
||||
}
|
||||
|
||||
this.manager.markTransactionCommitted(this.id);
|
||||
let historyCommitted = false;
|
||||
try {
|
||||
historyCommitted = this.hasPendingWork()
|
||||
? this.commitHistoryEntry(options)
|
||||
: false;
|
||||
} finally {
|
||||
this.closeTransaction();
|
||||
}
|
||||
|
||||
const status = this.status;
|
||||
this.cachedSummary = {
|
||||
id: this.id,
|
||||
status,
|
||||
historyCommitted,
|
||||
};
|
||||
this.ledger.clear();
|
||||
return this.cachedSummary;
|
||||
}
|
||||
|
||||
private hasPendingWork() {
|
||||
return this.ledger.hasEntries() || this.hasAccumulatedAppStateIntent();
|
||||
}
|
||||
|
||||
private hasAccumulatedAppStateIntent() {
|
||||
return Object.keys(this.accumulatedAppState).length > 0;
|
||||
}
|
||||
|
||||
private commitHistoryEntry(options?: CommitOptions) {
|
||||
const liveMap = this.app.scene.getElementsMapIncludingDeleted();
|
||||
const { elementsBefore, elementsAfter } =
|
||||
this.ledger.buildSyntheticSnapshots(liveMap);
|
||||
|
||||
const appStateDelta = this.resolveCommitAppStateDelta(options);
|
||||
|
||||
return this.app.store.commitSyntheticIncrement({
|
||||
logicalBefore: { elements: elementsBefore },
|
||||
logicalAfter: {
|
||||
elements: elementsAfter,
|
||||
appState: appStateDelta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveCommitAppStateDelta(
|
||||
options?: CommitOptions,
|
||||
): Partial<ObservedAppState> | undefined {
|
||||
if (!this.hasAccumulatedAppStateIntent()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!options?.resolveAppState) {
|
||||
return this.accumulatedAppState as Partial<ObservedAppState>;
|
||||
}
|
||||
|
||||
const context: AppStateResolverContext = {
|
||||
initial: this.initialAppState,
|
||||
accumulated: this.accumulatedAppState as Partial<ObservedAppState>,
|
||||
live: { ...this.app.store.snapshot.appState },
|
||||
};
|
||||
const resolved = options.resolveAppState(context);
|
||||
|
||||
if (!resolved || Object.keys(resolved).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
cancel(): TransactionSummary {
|
||||
if (this.cachedSummary) {
|
||||
return this.cachedSummary;
|
||||
}
|
||||
|
||||
if (this.status === "active") {
|
||||
this.manager.markTransactionCanceled(this.id);
|
||||
}
|
||||
|
||||
this.closeTransaction();
|
||||
const status = this.status;
|
||||
this.cachedSummary = {
|
||||
id: this.id,
|
||||
status,
|
||||
historyCommitted: false,
|
||||
};
|
||||
this.ledger.clear();
|
||||
return this.cachedSummary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ElementUpdate } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawNonSelectionElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { ObservedAppState } from "../types";
|
||||
|
||||
export type ElementPropName = Extract<keyof ExcalidrawElement, string>;
|
||||
|
||||
export type TouchedElementProps =
|
||||
| { kind: "all" }
|
||||
| { kind: "partial"; props: Set<ElementPropName> };
|
||||
|
||||
export type ElementChange = {
|
||||
id: ExcalidrawElement["id"];
|
||||
before: ExcalidrawElement | null;
|
||||
after: ExcalidrawElement | null;
|
||||
touchedProps: TouchedElementProps;
|
||||
};
|
||||
|
||||
/** Per-element ledger record captured during a transaction session. */
|
||||
export type TransactionLedgerEntry = {
|
||||
baselineElement: ExcalidrawElement | null;
|
||||
targetElement: ExcalidrawElement | null;
|
||||
touchedProps: TouchedElementProps;
|
||||
};
|
||||
|
||||
/** Lifecycle state of a transaction. */
|
||||
export type TransactionStatus = "active" | "committed" | "canceled";
|
||||
|
||||
/** Per-element partial patch used by tx.updateElements(). */
|
||||
export type TransactionUpdatableElementType =
|
||||
ExcalidrawNonSelectionElement["type"];
|
||||
|
||||
export type TransactionElementOfType<
|
||||
TType extends TransactionUpdatableElementType,
|
||||
> = Extract<ExcalidrawNonSelectionElement, { type: TType }>;
|
||||
|
||||
export type TransactionElementUpdate<
|
||||
TType extends TransactionUpdatableElementType = TransactionUpdatableElementType,
|
||||
> = TType extends TransactionUpdatableElementType
|
||||
? {
|
||||
id: ExcalidrawElement["id"];
|
||||
type: TType;
|
||||
updates: ElementUpdate<TransactionElementOfType<TType>>;
|
||||
}
|
||||
: never;
|
||||
|
||||
/** Final summary returned when a transaction is committed or canceled. */
|
||||
export type TransactionSummary = {
|
||||
id: string;
|
||||
status: TransactionStatus;
|
||||
historyCommitted: boolean;
|
||||
};
|
||||
|
||||
/** Three-way appState context provided to the resolver at commit time. */
|
||||
export type AppStateResolverContext = {
|
||||
/** AppState snapshot captured when the transaction was created. */
|
||||
initial: Partial<ObservedAppState>;
|
||||
/** Merged appState intent from all updateScene calls during the transaction. */
|
||||
accumulated: Partial<ObservedAppState>;
|
||||
/** Current live appState at commit time. */
|
||||
live: Partial<ObservedAppState>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Caller-provided resolver that determines which appState changes are
|
||||
* recorded in the history entry.
|
||||
*
|
||||
* Unlike elements — where per-property conflict detection works because
|
||||
* element properties are largely independent — appState keys are often
|
||||
* interdependent (e.g. selectedElementIds ↔ selectedGroupIds must stay
|
||||
* consistent). The correct merge strategy therefore depends on the
|
||||
* caller's semantic context, not on a generic policy.
|
||||
*
|
||||
* Return the appState delta to record in history, or undefined to skip
|
||||
* appState changes entirely.
|
||||
*/
|
||||
export type AppStateResolver = (
|
||||
context: AppStateResolverContext,
|
||||
) => Partial<ObservedAppState> | undefined;
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { StoreDelta, TxUndoOverride } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
TX_UNDO_OVERRIDE_IGNORED_PROPS,
|
||||
collectElementChanges,
|
||||
getElementProp,
|
||||
getElementPropEntries,
|
||||
getUpdatedElementEntries,
|
||||
hasTouchedProp,
|
||||
isPartialTouchedProps,
|
||||
isLedgerValueEqual,
|
||||
serializeConsumedPropKey,
|
||||
serializeIntermediateValue,
|
||||
touchesWholeElement,
|
||||
type ElementUpdatedEntry,
|
||||
} from "./diff";
|
||||
|
||||
import type {
|
||||
ElementPropName,
|
||||
TouchedElementProps,
|
||||
TransactionLedgerEntry,
|
||||
} from "./types";
|
||||
|
||||
type TxUndoOverrideCandidate = Omit<TxUndoOverride, "txId">;
|
||||
|
||||
/**
|
||||
* Per-element-prop history of tx intermediate values.
|
||||
*
|
||||
* We keep:
|
||||
* - the full sequence for exact deep-equality fallback
|
||||
* - a serialized signature set for fast negative lookups
|
||||
* - the latest value for the most common positive lookup path
|
||||
*/
|
||||
class TxIntermediateValueHistory {
|
||||
private readonly values: unknown[] = [];
|
||||
private readonly signatures = new Set<string>();
|
||||
private latestValue: unknown;
|
||||
private hasLatestValue = false;
|
||||
|
||||
/** Appends a new intermediate value, skipping consecutive duplicates. */
|
||||
add(value: unknown) {
|
||||
if (this.hasLatestValue && isLedgerValueEqual(this.latestValue, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.values.push(value);
|
||||
this.signatures.add(serializeIntermediateValue(value));
|
||||
this.latestValue = value;
|
||||
this.hasLatestValue = true;
|
||||
}
|
||||
|
||||
/** Returns whether the candidate appeared in this tx prop history. */
|
||||
contains(candidate: unknown) {
|
||||
const candidateSignature = serializeIntermediateValue(candidate);
|
||||
if (!this.signatures.has(candidateSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.hasLatestValue &&
|
||||
isLedgerValueEqual(this.latestValue, candidate)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.values.some((value) => isLedgerValueEqual(value, candidate));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks tx intermediate values and computes undo baseline override markers
|
||||
* for durable user deltas recorded while tx is active.
|
||||
*
|
||||
* High-level flow:
|
||||
* 1. `recordStep()` observes every in-tx scene mutation and records the
|
||||
* intermediate value reached by each touched element prop.
|
||||
* 2. When a durable user delta is about to be recorded,
|
||||
* `collectCandidatesForDurableDelta()` checks whether that delta's
|
||||
* deleted-baseline values match any tx intermediate value.
|
||||
* 3. If they do, we emit override candidates so undo can restore the pre-tx
|
||||
* baseline once the tx has ended.
|
||||
* 4. `markConsumed()` ensures only the first polluted durable entry for a
|
||||
* given element+prop gets patched; later user actions keep their own
|
||||
* action-local undo baseline.
|
||||
*/
|
||||
export class TxUndoOverridePlanner {
|
||||
private readonly intermediateValuesByElementProp = new Map<
|
||||
string,
|
||||
Map<ElementPropName, TxIntermediateValueHistory>
|
||||
>();
|
||||
private readonly consumedOverridePropKeys = new Set<string>();
|
||||
|
||||
/** Resets planner state when the transaction finishes. */
|
||||
clear() {
|
||||
this.consumedOverridePropKeys.clear();
|
||||
this.intermediateValuesByElementProp.clear();
|
||||
}
|
||||
|
||||
/** Records per-prop intermediate values reached by one in-tx scene step. */
|
||||
recordStep(
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
) {
|
||||
for (const change of collectElementChanges(before, after)) {
|
||||
const { id: elementId, after: afterElement, touchedProps } = change;
|
||||
|
||||
if (!afterElement || !isPartialTouchedProps(touchedProps)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const prop of touchedProps.props) {
|
||||
this.recordIntermediateValue(
|
||||
elementId,
|
||||
prop,
|
||||
getElementProp(afterElement, prop),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects override candidates for one durable user delta recorded while the
|
||||
* tx is active.
|
||||
*/
|
||||
collectCandidatesForDurableDelta(
|
||||
delta: StoreDelta,
|
||||
getLedgerEntry: (elementId: string) => TransactionLedgerEntry | undefined,
|
||||
reservedConsumedKeys: Set<string>,
|
||||
): TxUndoOverrideCandidate[] {
|
||||
const candidates: TxUndoOverrideCandidate[] = [];
|
||||
|
||||
for (const [elementId, deltaEntry] of Object.entries(
|
||||
getUpdatedElementEntries(delta),
|
||||
)) {
|
||||
const ledgerEntry = getLedgerEntry(elementId);
|
||||
if (!ledgerEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const elementCandidates = this.collectCandidatesForElement(
|
||||
elementId,
|
||||
deltaEntry,
|
||||
ledgerEntry,
|
||||
reservedConsumedKeys,
|
||||
);
|
||||
if (elementCandidates.length > 0) {
|
||||
candidates.push(...elementCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** Evaluates one element's updated entry against the tx ledger snapshot. */
|
||||
private collectCandidatesForElement(
|
||||
elementId: string,
|
||||
deltaEntry: ElementUpdatedEntry,
|
||||
ledgerEntry: TransactionLedgerEntry,
|
||||
reservedConsumedKeys: Set<string>,
|
||||
): TxUndoOverrideCandidate[] {
|
||||
const { baselineElement, touchedProps } = ledgerEntry;
|
||||
if (!baselineElement || touchesWholeElement(touchedProps)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: TxUndoOverrideCandidate[] = [];
|
||||
for (const [prop, deletedValue] of getElementPropEntries(
|
||||
deltaEntry.deleted,
|
||||
)) {
|
||||
const candidate = this.createCandidateForProp({
|
||||
elementId,
|
||||
prop,
|
||||
deletedValue,
|
||||
baselineElement,
|
||||
touchedProps,
|
||||
reservedConsumedKeys,
|
||||
});
|
||||
if (candidate) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an override candidate for one element+prop when the durable delta's
|
||||
* deleted baseline was polluted by a tx intermediate value.
|
||||
*/
|
||||
private createCandidateForProp(args: {
|
||||
elementId: string;
|
||||
prop: ElementPropName;
|
||||
deletedValue: unknown;
|
||||
baselineElement: ExcalidrawElement;
|
||||
touchedProps: TouchedElementProps;
|
||||
reservedConsumedKeys: Set<string>;
|
||||
}): TxUndoOverrideCandidate | null {
|
||||
const { elementId, prop, deletedValue, baselineElement, touchedProps } =
|
||||
args;
|
||||
|
||||
if (
|
||||
TX_UNDO_OVERRIDE_IGNORED_PROPS.has(prop) ||
|
||||
!hasTouchedProp(touchedProps, prop)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const consumedPropKey = serializeConsumedPropKey(elementId, prop);
|
||||
// Override only the first polluted user entry for this element+prop.
|
||||
// Later user actions should keep action-local undo baselines.
|
||||
if (
|
||||
this.consumedOverridePropKeys.has(consumedPropKey) ||
|
||||
args.reservedConsumedKeys.has(consumedPropKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.matchesIntermediateValue(elementId, prop, deletedValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
elementId,
|
||||
prop,
|
||||
expectedInsertedValue: deletedValue,
|
||||
preTxBaselineValue: getElementProp(baselineElement, prop),
|
||||
consumedKey: consumedPropKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks an element+prop override as consumed by an earlier durable entry. */
|
||||
markConsumed(consumedPropKey: string) {
|
||||
this.consumedOverridePropKeys.add(consumedPropKey);
|
||||
}
|
||||
|
||||
/** Returns the per-prop history map for one element, creating it if needed. */
|
||||
private getOrCreatePropValues(elementId: string) {
|
||||
const existing = this.intermediateValuesByElementProp.get(elementId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = new Map<ElementPropName, TxIntermediateValueHistory>();
|
||||
this.intermediateValuesByElementProp.set(elementId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Appends one observed intermediate value for an element prop. */
|
||||
private recordIntermediateValue(
|
||||
elementId: string,
|
||||
prop: ElementPropName,
|
||||
value: unknown,
|
||||
) {
|
||||
const propValues = this.getOrCreatePropValues(elementId);
|
||||
const history = propValues.get(prop);
|
||||
if (history) {
|
||||
history.add(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHistory = new TxIntermediateValueHistory();
|
||||
nextHistory.add(value);
|
||||
propValues.set(prop, nextHistory);
|
||||
}
|
||||
|
||||
/** Checks whether a durable delta baseline matches any tx intermediate value. */
|
||||
private matchesIntermediateValue(
|
||||
elementId: string,
|
||||
prop: ElementPropName,
|
||||
candidate: unknown,
|
||||
) {
|
||||
const history = this.intermediateValuesByElementProp
|
||||
.get(elementId)
|
||||
?.get(prop);
|
||||
if (!history) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return history.contains(candidate);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
OrderedExcalidrawElement,
|
||||
ExcalidrawNonSelectionElement,
|
||||
BindMode,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@@ -268,6 +269,8 @@ export type ObservedElementsAppState = {
|
||||
activeLockedId: AppState["activeLockedId"];
|
||||
};
|
||||
|
||||
export type BoxSelectionMode = "contain" | "overlap";
|
||||
|
||||
export interface AppState {
|
||||
contextMenu: {
|
||||
items: ContextMenuItems;
|
||||
@@ -306,6 +309,8 @@ export interface AppState {
|
||||
* `bindingPreference` and keyboard modifiers (ctrl/alt)
|
||||
*/
|
||||
isBindingEnabled: boolean;
|
||||
/** user box selection preference; defaults to "contain" when unset */
|
||||
boxSelectionMode: BoxSelectionMode;
|
||||
/** user arrow binding preference */
|
||||
bindingPreference: "enabled" | "disabled";
|
||||
/** user preference whether arrow snap to midpoints while binding */
|
||||
@@ -327,7 +332,7 @@ export interface AppState {
|
||||
/**
|
||||
* set when a new text is created or when an existing text is being edited
|
||||
*/
|
||||
editingTextElement: NonDeletedExcalidrawElement | null;
|
||||
editingTextElement: ExcalidrawTextElement | null;
|
||||
activeTool: {
|
||||
/**
|
||||
* indicates a previous tool we should revert back to if we deselect the
|
||||
@@ -573,11 +578,15 @@ export interface ExcalidrawProps {
|
||||
* Invoked as soon as the Excalidraw API is available
|
||||
* NOTE editor is not yet mounted, and state is not yet initialized
|
||||
*/
|
||||
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
|
||||
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => void;
|
||||
/**
|
||||
* Invoked once the editor root is mounted.
|
||||
*/
|
||||
onMount?: (payload: ExcalidrawMountPayload) => void;
|
||||
/**
|
||||
* Invoked when the editor root is unmounted.
|
||||
*/
|
||||
onUnmount?: () => void;
|
||||
/**
|
||||
* Invoked once the initial scene is loaded.
|
||||
*/
|
||||
@@ -787,6 +796,9 @@ export type AppClassProperties = {
|
||||
files: BinaryFiles;
|
||||
editorInterface: App["editorInterface"];
|
||||
scene: App["scene"];
|
||||
store: App["store"];
|
||||
transactionManager: App["transactionManager"];
|
||||
createTransaction: App["createTransaction"];
|
||||
syncActionResult: App["syncActionResult"];
|
||||
fonts: App["fonts"];
|
||||
pasteFromClipboard: App["pasteFromClipboard"];
|
||||
@@ -872,9 +884,8 @@ export type PointerDownState = Readonly<{
|
||||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||||
// to current pointer position at time of duplication.
|
||||
origin: { x: number; y: number };
|
||||
// Whether to block drag after lasso selection
|
||||
// this is meant to be used to block dragging after lasso selection on PCs
|
||||
// until the next pointer down
|
||||
// explicit flag for specific scenarios such as:
|
||||
// - after lasso selection until the next pointer down
|
||||
blockDragging: boolean;
|
||||
};
|
||||
// We need to have these in the state so that we can unsubscribe them
|
||||
@@ -907,9 +918,12 @@ export type ExcalidrawMountPayload = {
|
||||
export type ExcalidrawImperativeAPIEventMap = {
|
||||
"editor:mount": [payload: ExcalidrawMountPayload];
|
||||
"editor:initialize": [api: ExcalidrawImperativeAPI];
|
||||
"editor:unmount": [];
|
||||
};
|
||||
|
||||
export interface ExcalidrawImperativeAPI {
|
||||
/** Whether the editor has been unmounted and the API is no longer usable. */
|
||||
isDestroyed: boolean;
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||||
mutateElement: InstanceType<typeof App>["mutateElement"];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { queryByText } from "@testing-library/react";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { getOriginalContainerHeightFromCache } from "@excalidraw/element";
|
||||
import {
|
||||
getLineHeightInPx,
|
||||
getOriginalContainerHeightFromCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
CODES,
|
||||
@@ -42,6 +45,28 @@ unmountComponent();
|
||||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const exitTextEditorAndAssertSelection = async ({
|
||||
editor,
|
||||
selectedIds,
|
||||
nextText,
|
||||
}: {
|
||||
editor: HTMLTextAreaElement;
|
||||
selectedIds: string[];
|
||||
nextText?: string;
|
||||
}) => {
|
||||
if (nextText !== undefined) {
|
||||
updateTextEditor(editor, nextText);
|
||||
}
|
||||
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
|
||||
expect(window.h.state.editingTextElement).toBeNull();
|
||||
expect(API.getSelectedElements().map((element) => element.id)).toEqual(
|
||||
selectedIds,
|
||||
);
|
||||
};
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
@@ -210,6 +235,42 @@ describe("textWysiwyg", () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should vertically center newly created text on the cursor when clicked with text tool", async () => {
|
||||
API.setAppState({
|
||||
currentItemFontFamily: FONT_FAMILY.Cascadia,
|
||||
currentItemFontSize: 40,
|
||||
});
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(120, 80);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
const lineHeightPx = getLineHeightInPx(text.fontSize, text.lineHeight);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(text.y + lineHeightPx / 2).toBe(80);
|
||||
});
|
||||
|
||||
it("should snap newly created text top-left to the current grid cell when clicked with text tool in grid mode", async () => {
|
||||
API.setAppState({
|
||||
currentItemFontFamily: FONT_FAMILY.Cascadia,
|
||||
currentItemFontSize: 40,
|
||||
gridModeEnabled: true,
|
||||
gridSize: 24,
|
||||
});
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(113, 86);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(text.x).toBe(96);
|
||||
expect(text.y).toBe(72);
|
||||
});
|
||||
|
||||
it("should edit text under cursor when double-clicked with selection tool", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
@@ -232,6 +293,94 @@ describe("textWysiwyg", () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should reselect text after exiting wysiwyg with escape", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([text]);
|
||||
API.setSelectedElements([text]);
|
||||
UI.clickTool("selection");
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||
|
||||
await exitTextEditorAndAssertSelection({
|
||||
editor,
|
||||
selectedIds: [text.id],
|
||||
});
|
||||
});
|
||||
|
||||
it("should edit selected bound text on single click", async () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 160,
|
||||
height: 70,
|
||||
boundElements: [],
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Hello World!",
|
||||
x: container.x + 20,
|
||||
y: container.y + 20,
|
||||
width: 120,
|
||||
height: 25,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
API.setElements([container, text]);
|
||||
API.updateElement(container, {
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
});
|
||||
API.setSelectedElements([container]);
|
||||
UI.clickTool("selection");
|
||||
|
||||
mouse.clickAt(text.x + 26, text.y + 10);
|
||||
|
||||
const editor = await getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should not edit selected bound text container when only the container was single-clicked", async () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 160,
|
||||
height: 70,
|
||||
boundElements: [],
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Hello World!",
|
||||
x: container.x + 20,
|
||||
y: container.y + 20,
|
||||
width: 120,
|
||||
height: 25,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
API.setElements([container, text]);
|
||||
API.updateElement(container, {
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
});
|
||||
API.setSelectedElements([container]);
|
||||
UI.clickTool("selection");
|
||||
|
||||
mouse.clickAt(container.x + 5, container.y + 10);
|
||||
|
||||
expect(h.state.editingTextElement).toBe(null);
|
||||
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
|
||||
});
|
||||
|
||||
// FIXME too flaky. No one knows why.
|
||||
it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
@@ -1205,6 +1354,40 @@ describe("textWysiwyg", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "container",
|
||||
createElements: () => API.createTextContainer(),
|
||||
},
|
||||
{
|
||||
label: "arrow",
|
||||
createElements: () => API.createLabeledArrow(),
|
||||
},
|
||||
])(
|
||||
"should reselect $label after deleting bound text with escape",
|
||||
async ({ createElements }) => {
|
||||
const [selectedElement, text] = createElements();
|
||||
API.setElements([selectedElement, text]);
|
||||
API.setSelectedElements([selectedElement]);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor();
|
||||
|
||||
await exitTextEditorAndAssertSelection({
|
||||
editor,
|
||||
nextText: "",
|
||||
selectedIds: [selectedElement.id],
|
||||
});
|
||||
|
||||
expect(selectedElement.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
isDeleted: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -1511,7 +1694,7 @@ describe("textWysiwyg", () => {
|
||||
version: 2,
|
||||
width: 610,
|
||||
x: 15,
|
||||
y: 25,
|
||||
y: 12.5,
|
||||
}),
|
||||
);
|
||||
expect(h.elements[2] as ExcalidrawTextElement).toEqual(
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
isTestEnv,
|
||||
MIME_TYPES,
|
||||
applyDarkModeFilter,
|
||||
isRTL,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
getTextFromElements,
|
||||
@@ -33,8 +35,11 @@ import {
|
||||
getBoundTextElement,
|
||||
} from "@excalidraw/element";
|
||||
import { getTextWidth } from "@excalidraw/element";
|
||||
import { getLineHeightInPx } from "@excalidraw/element";
|
||||
import { getLineWidth } from "@excalidraw/element";
|
||||
import { normalizeText } from "@excalidraw/element";
|
||||
import { wrapText } from "@excalidraw/element";
|
||||
import { getWrappedTextLines } from "@excalidraw/element";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
@@ -91,6 +96,103 @@ const getTransform = (
|
||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||
};
|
||||
|
||||
const getLineDirection = (text: string, offset: number) => {
|
||||
const hardLineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
||||
const hardLineEnd = text.indexOf("\n", offset);
|
||||
const hardLineText = text.slice(
|
||||
hardLineStart,
|
||||
hardLineEnd === -1 ? text.length : hardLineEnd,
|
||||
);
|
||||
|
||||
return isRTL(hardLineText) ? "rtl" : "ltr";
|
||||
};
|
||||
|
||||
const getCaretBoundaryOffsets = (text: string) => {
|
||||
const offsets = [0];
|
||||
let offset = 0;
|
||||
|
||||
for (const char of Array.from(text)) {
|
||||
offset += char.length;
|
||||
offsets.push(offset);
|
||||
}
|
||||
|
||||
return offsets;
|
||||
};
|
||||
|
||||
const getLineCaretOffsetFromNativeLayout = ({
|
||||
text,
|
||||
font,
|
||||
lineHeightPx,
|
||||
direction,
|
||||
targetX,
|
||||
}: {
|
||||
text: string;
|
||||
font: ReturnType<typeof getFontString>;
|
||||
lineHeightPx: number;
|
||||
direction: "ltr" | "rtl";
|
||||
targetX: number;
|
||||
}) => {
|
||||
if (!text || !document.body || typeof document.createRange !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offsets = getCaretBoundaryOffsets(text);
|
||||
const mirror = document.createElement("div");
|
||||
const textNode = document.createTextNode(text);
|
||||
const range = document.createRange();
|
||||
const positions: number[] = [];
|
||||
|
||||
mirror.dir = direction;
|
||||
Object.assign(mirror.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre",
|
||||
font,
|
||||
lineHeight: `${lineHeightPx}px`,
|
||||
});
|
||||
mirror.append(textNode);
|
||||
document.body.append(mirror);
|
||||
|
||||
try {
|
||||
for (const offset of offsets) {
|
||||
range.setStart(textNode, offset);
|
||||
range.setEnd(textNode, offset);
|
||||
const caretRect = range.getBoundingClientRect();
|
||||
|
||||
if (!Number.isFinite(caretRect.left)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
positions.push(caretRect.left);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
mirror.remove();
|
||||
}
|
||||
|
||||
const leftEdge = Math.min(...positions);
|
||||
let closestOffset = offsets[0];
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (let index = 0; index < offsets.length; index++) {
|
||||
const distance = Math.abs(positions[index] - leftEdge - targetX);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestOffset = offsets[index];
|
||||
}
|
||||
}
|
||||
|
||||
return closestOffset;
|
||||
};
|
||||
|
||||
type SubmitHandler = () => void;
|
||||
|
||||
export const textWysiwyg = ({
|
||||
@@ -103,6 +205,7 @@ export const textWysiwyg = ({
|
||||
excalidrawContainer,
|
||||
app,
|
||||
autoSelect = true,
|
||||
initialCaretSceneCoords = null,
|
||||
}: {
|
||||
id: ExcalidrawElement["id"];
|
||||
/**
|
||||
@@ -119,7 +222,19 @@ export const textWysiwyg = ({
|
||||
excalidrawContainer: HTMLDivElement | null;
|
||||
app: App;
|
||||
autoSelect?: boolean;
|
||||
initialCaretSceneCoords?: { x: number; y: number } | null;
|
||||
}): SubmitHandler => {
|
||||
let currentTextLayout: {
|
||||
angle: Radians;
|
||||
font: ReturnType<typeof getFontString>;
|
||||
height: number;
|
||||
lineHeightPx: number;
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null = null;
|
||||
|
||||
const textPropertiesUpdated = (
|
||||
updatedTextElement: ExcalidrawTextElement,
|
||||
editable: HTMLTextAreaElement,
|
||||
@@ -254,6 +369,7 @@ export const textWysiwyg = ({
|
||||
height *= 1.05;
|
||||
|
||||
const font = getFontString(updatedTextElement);
|
||||
const angle = getTextElementAngle(updatedTextElement, container);
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
@@ -269,7 +385,7 @@ export const textWysiwyg = ({
|
||||
transform: getTransform(
|
||||
width,
|
||||
height,
|
||||
getTextElementAngle(updatedTextElement, container),
|
||||
angle,
|
||||
appState,
|
||||
maxWidth,
|
||||
editorMaxHeight,
|
||||
@@ -283,6 +399,19 @@ export const textWysiwyg = ({
|
||||
opacity: updatedTextElement.opacity / 100,
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
currentTextLayout = {
|
||||
angle: angle as Radians,
|
||||
font,
|
||||
height: updatedTextElement.height,
|
||||
lineHeightPx: getLineHeightInPx(
|
||||
updatedTextElement.fontSize,
|
||||
updatedTextElement.lineHeight,
|
||||
),
|
||||
textAlign,
|
||||
width: updatedTextElement.width,
|
||||
x: coordX,
|
||||
y: coordY,
|
||||
};
|
||||
editable.scrollTop = 0;
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
@@ -333,6 +462,71 @@ export const textWysiwyg = ({
|
||||
editable.value = element.originalText;
|
||||
updateWysiwygStyle();
|
||||
|
||||
const getCaretIndexFromInitialSceneCoords = () => {
|
||||
if (!initialCaretSceneCoords || !currentTextLayout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layout = currentTextLayout;
|
||||
const center = pointFrom(
|
||||
layout.x + layout.width / 2,
|
||||
layout.y + layout.height / 2,
|
||||
);
|
||||
const [unrotatedX, unrotatedY] = pointRotateRads(
|
||||
pointFrom(initialCaretSceneCoords.x, initialCaretSceneCoords.y),
|
||||
center,
|
||||
-layout.angle as Radians,
|
||||
);
|
||||
const localX = unrotatedX - layout.x;
|
||||
const localY = unrotatedY - layout.y;
|
||||
const lines = getWrappedTextLines(
|
||||
editable.value,
|
||||
layout.font,
|
||||
whiteSpace === "pre-wrap" ? layout.width : Infinity,
|
||||
);
|
||||
const lineIndex = Math.max(
|
||||
0,
|
||||
Math.min(lines.length - 1, Math.floor(localY / layout.lineHeightPx)),
|
||||
);
|
||||
const line = lines[lineIndex];
|
||||
const direction = getLineDirection(editable.value, line.start);
|
||||
const lineWidth = getLineWidth(line.text, layout.font);
|
||||
const lineStartX =
|
||||
layout.textAlign === "center"
|
||||
? (layout.width - lineWidth) / 2
|
||||
: layout.textAlign === "right"
|
||||
? layout.width - lineWidth
|
||||
: 0;
|
||||
const relativeX = localX - lineStartX;
|
||||
|
||||
if (!line.text) {
|
||||
return line.start;
|
||||
}
|
||||
|
||||
const lineCaretOffset = getLineCaretOffsetFromNativeLayout({
|
||||
text: line.text,
|
||||
font: layout.font,
|
||||
lineHeightPx: layout.lineHeightPx,
|
||||
direction,
|
||||
targetX: relativeX,
|
||||
});
|
||||
|
||||
return line.start + (lineCaretOffset || 0);
|
||||
};
|
||||
|
||||
let pendingInitialSelection = (() => {
|
||||
const caretIndex = getCaretIndexFromInitialSceneCoords();
|
||||
|
||||
if (caretIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: caretIndex,
|
||||
end: caretIndex,
|
||||
};
|
||||
})();
|
||||
|
||||
if (onChange) {
|
||||
editable.onpaste = async (event) => {
|
||||
// we need to synchronously get the MIME types so we can preventDefault()
|
||||
@@ -696,6 +890,13 @@ export const textWysiwyg = ({
|
||||
// Otherwise, re-enable submit on blur and refocus the editor.
|
||||
editable.onblur = handleSubmit;
|
||||
editable.focus();
|
||||
if (pendingInitialSelection) {
|
||||
editable.setSelectionRange(
|
||||
pendingInitialSelection.start,
|
||||
pendingInitialSelection.end,
|
||||
);
|
||||
pendingInitialSelection = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -786,7 +987,7 @@ export const textWysiwyg = ({
|
||||
|
||||
let isDestroyed = false;
|
||||
|
||||
if (autoSelect) {
|
||||
if (autoSelect && !pendingInitialSelection) {
|
||||
// select on init (focusing is done separately inside the bindBlurEvent()
|
||||
// because we need it to happen *after* the blur event from `pointerdown`)
|
||||
editable.select();
|
||||
|
||||
@@ -137,8 +137,17 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
|
||||
[t0, s0]: [number, number],
|
||||
l: LineSegment<Point>,
|
||||
c: Curve<Point>,
|
||||
tolerance: number = 1e-2,
|
||||
iterLimit: number = 4,
|
||||
) => {
|
||||
const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4);
|
||||
const solution = solveWithAnalyticalJacobian(
|
||||
c,
|
||||
l,
|
||||
t0,
|
||||
s0,
|
||||
tolerance,
|
||||
iterLimit,
|
||||
);
|
||||
|
||||
if (!solution) {
|
||||
return null;
|
||||
@@ -158,18 +167,43 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
|
||||
*/
|
||||
export function curveIntersectLineSegment<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
|
||||
let solution = calculate(initial_guesses[0], l, c);
|
||||
>(
|
||||
c: Curve<Point>,
|
||||
l: LineSegment<Point>,
|
||||
opts?: {
|
||||
tolerance?: number;
|
||||
iterLimit?: number;
|
||||
},
|
||||
): Point[] {
|
||||
let solution = calculate(
|
||||
initial_guesses[0],
|
||||
l,
|
||||
c,
|
||||
opts?.tolerance,
|
||||
opts?.iterLimit,
|
||||
);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
solution = calculate(initial_guesses[1], l, c);
|
||||
solution = calculate(
|
||||
initial_guesses[1],
|
||||
l,
|
||||
c,
|
||||
opts?.tolerance,
|
||||
opts?.iterLimit,
|
||||
);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
solution = calculate(initial_guesses[2], l, c);
|
||||
solution = calculate(
|
||||
initial_guesses[2],
|
||||
l,
|
||||
c,
|
||||
opts?.tolerance,
|
||||
opts?.iterLimit,
|
||||
);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
@@ -123,10 +123,15 @@ export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
|
||||
* @returns The rotated point
|
||||
*/
|
||||
export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
|
||||
[x, y]: Point,
|
||||
[cx, cy]: Point,
|
||||
point: Point,
|
||||
center: Point,
|
||||
angle: Radians,
|
||||
): Point {
|
||||
if (!angle) {
|
||||
return point;
|
||||
}
|
||||
const [x, y] = point;
|
||||
const [cx, cy] = center;
|
||||
return pointFrom(
|
||||
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
|
||||
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
|
||||
|
||||
@@ -40,7 +40,7 @@ export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
|
||||
Array.isArray(segment) &&
|
||||
segment.length === 2 &&
|
||||
isPoint(segment[0]) &&
|
||||
isPoint(segment[0]);
|
||||
isPoint(segment[1]);
|
||||
|
||||
/**
|
||||
* Return the coordinates resulting from rotating the given line about an origin by an angle in radians
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { pointFrom } from "../src/point";
|
||||
import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment";
|
||||
import {
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
isLineSegment,
|
||||
} from "../src/segment";
|
||||
|
||||
describe("line-segment intersections", () => {
|
||||
it("should correctly detect intersection", () => {
|
||||
@@ -19,3 +23,23 @@ describe("line-segment intersections", () => {
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
describe("isLineSegment validation", () => {
|
||||
it("should return true for a valid segment", () => {
|
||||
expect(
|
||||
isLineSegment([
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if second element is not a point", () => {
|
||||
const invalidSegment = [[0, 0], "not-a-point"] as any;
|
||||
|
||||
expect(isLineSegment(invalidSegment)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for wrong length", () => {
|
||||
expect(isLineSegment([[0, 0]])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
},
|
||||
"bindMode": "orbit",
|
||||
"bindingPreference": "enabled",
|
||||
"boxSelectionMode": "contain",
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElementId": null,
|
||||
|
||||
@@ -90,6 +90,13 @@ module.exports.woff2BrowserPlugin = () => {
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="${OSS_FONTS_CDN}fonts/Assistant/Assistant-SemiBold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2"
|
||||
|
||||
@@ -1531,10 +1531,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
||||
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
||||
|
||||
"@excalidraw/mermaid-to-excalidraw@2.0.0-rc4":
|
||||
version "2.0.0-rc4"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03"
|
||||
integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw==
|
||||
"@excalidraw/mermaid-to-excalidraw@2.2.2":
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0"
|
||||
integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==
|
||||
dependencies:
|
||||
"@excalidraw/markdown-to-text" "0.1.2"
|
||||
"@mermaid-js/parser" "^0.6.3"
|
||||
|
||||
Reference in New Issue
Block a user