Compare commits

..
Author SHA1 Message Date
dwelle 1625edeb07 wip 2026-03-14 12:38:38 +01:00
90 changed files with 1505 additions and 8135 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
steps: steps:
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 - uses: styfle/cancel-workflow-action@0.6.0
with: with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }} access_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body echo ::set-output name=body::$body
- name: Update description with coverage - name: Update description with coverage
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 uses: kt3k/update-pr-description@v1.0.1
with: with:
pr_body: ${{ steps.getCommentBody.outputs.body }} pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin" pr_title: "chore: Update translations from Crowdin"
+4 -4
View File
@@ -13,16 +13,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 - uses: amannn/action-semantic-pull-request@v5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw working-directory: packages/excalidraw
env: env:
CI: true CI: true
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 - uses: andresz1/size-limit-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm build_script: build:esm
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage run: yarn test:coverage
- name: "Report Coverage" - name: "Report Coverage"
if: always() # Also generate the report if tests are failing if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 uses: davelosert/vitest-coverage-report-action@v2
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -29,7 +29,7 @@
<a href="https://docs.excalidraw.com/docs/introduction/contributing"> <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> <img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="https://discord.gg/UexuTaE"> <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&widget=false"/></a> <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>
<a href="https://deepwiki.com/excalidraw/excalidraw"> <a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a> <img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
-125
View File
@@ -1,125 +0,0 @@
# 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.
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE", href: "https://discord.gg/UexuTaE",
}, },
{ {
label: "𝕏", label: "Twitter",
href: "https://x.com/excalidraw", href: "https://twitter.com/excalidraw",
}, },
{ {
label: "Linkedin", label: "Linkedin",
+12 -8
View File
@@ -25,15 +25,19 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({ const blob = await exportToBlob({
elements: children, data: {
appState: { elements: children,
...appState, appState: {
exportBackground: true, ...appState,
viewBackgroundColor: appState.viewBackgroundColor, exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
}, },
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
}); });
const dataURL = await getDataURL(blob); const dataURL = await getDataURL(blob);
+4 -24
View File
@@ -680,9 +680,8 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>( export const getBoundsFromPoints = (
points: readonly P[], points: ExcalidrawFreeDrawElement["points"],
padding: number = 0,
): Bounds => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
@@ -696,7 +695,7 @@ export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return [minX - padding, minY - padding, maxX + padding, maxY + padding]; return [minX, minY, maxX, maxY];
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -1262,17 +1261,6 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean => ): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; 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 = ( export const doBoundsIntersect = (
bounds1: Bounds | null, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
@@ -1287,21 +1275,13 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; 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 = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+17 -39
View File
@@ -154,11 +154,14 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds( const hitBounds = isPointWithinBounds(
point, pointFrom(bounds[0] - threshold, bounds[1] - threshold),
bounds, pointRotateRads(
element.angle, point,
threshold, getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -189,32 +192,18 @@ export const hitElementItself = ({
return result; 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 = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
const bounds = getElementBounds(element, elementsMap, true); let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance); x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
@@ -476,12 +465,7 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment( return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -548,15 +532,11 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = ( const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>, segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false, onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements // NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions. // rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -584,9 +564,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue; continue;
} }
const hits = curveIntersectLineSegment(c, segment, { const hits = curveIntersectLineSegment(c, segment);
iterLimit: 10,
});
if (hits.length > 0) { if (hits.length > 0) {
intersections.push(...hits); intersections.push(...hits);
+2 -6
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p); return distanceToLinearOrFreeDraElement(element, p);
} }
}; };
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
return Math.min( return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)), ...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)), ...curves.map((a) => curvePointDistance(a, p)),
+9 -11
View File
@@ -1,6 +1,7 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math"; import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -17,8 +18,6 @@ import {
getElementLineSegments, getElementLineSegments,
getCommonBounds, getCommonBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
} from "./bounds"; } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -921,17 +920,16 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => { ) => {
return elements.filter( return (
(el) => elementsOverlappingBBox({
// exclude elements which are overlapping, but are in a different frame, elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
// and thus invisible in target frame // and thus invisible in target frame
(!el.frameId || el.frameId === frame.id) && .filter((el) => !el.frameId || el.frameId === frame.id)
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
); );
}; };
+14 -31
View File
@@ -476,22 +476,16 @@ export class LinearElementEditor {
}); });
} }
if ( invariant(
lastClickedPoint < 0 || lastClickedPoint > -1 &&
!selectedPointsIndices.includes(lastClickedPoint) || selectedPointsIndices.includes(lastClickedPoint) &&
!element.points[lastClickedPoint] element.points[lastClickedPoint],
) { `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
console.error( selectedPointsIndices,
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( )}) points(0..${
selectedPointsIndices, element.points.length - 1
)}) points(0..${ }) lastClickedPoint(${lastClickedPoint})`,
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) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
@@ -800,7 +794,6 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -810,7 +803,6 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
@@ -898,7 +890,6 @@ export class LinearElementEditor {
endPoint: P, endPoint: P,
index: number, index: number,
zoom: Zoom, zoom: Zoom,
elementsMap: ElementsMap,
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) { if (index >= 0 && index < element.points.length) {
@@ -913,10 +904,7 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint); let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -936,7 +924,6 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
index: number, index: number,
elementsMap: ElementsMap,
): GlobalPoint { ): GlobalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
invariant( invariant(
@@ -949,10 +936,7 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]); return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
} }
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1867,7 +1851,6 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2417,7 +2400,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2448,7 +2431,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+36 -278
View File
@@ -1,71 +1,33 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import {
isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
getFrameChildren, getFrameChildren,
isElementIntersectingFrame,
} from "./frame"; } from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups"; import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } 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. * 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 * Given an array of selected elements, if there are frames and their containing elements
@@ -85,243 +47,55 @@ export const excludeElementsInFramesFromSelection = <
} }
}); });
return excludeElementsFromFrames(selectedElements, framesInSelection); return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
}; };
export const getElementsWithinSelection = ( export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain", ) => {
): NonDeletedExcalidrawElement[] => { const [selectionX1, selectionY1, selectionX2, selectionY2] =
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); 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),
),
];
const framesInSelection = excludeElementsInFrames let elementsInSelection = elements.filter((element) => {
? new Set<NonDeletedExcalidrawElement["id"]>() let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
: null; element,
let elementsInSelection: NonDeletedExcalidrawElement[] = []; elementsMap,
);
for (const element of elements) { const containingFrame = getContainingFrame(element, elementsMap);
if (shouldIgnoreElementFromSelection(element)) { if (containingFrame) {
continue; const [fx1, fy1, fx2, fy2] = getElementBounds(
} containingFrame,
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap, elementsMap,
); );
labelAABB = [
x, elementX1 = Math.max(fx1, elementX1);
y, elementY1 = Math.max(fy1, elementY1);
x + boundTextElement.width, elementX2 = Math.min(fx2, elementX2);
y + boundTextElement.height, elementY2 = Math.min(fy2, elementY2);
] as Bounds;
} }
// Clip element bounds by its containing frame (if any), since only the return (
// visible (frame-clipped) portion of the element is relevant for selection. element.locked === false &&
const associatedFrame = getContainingFrame(element, elementsMap); element.type !== "selection" &&
if ( !isBoundToContainer(element) &&
associatedFrame && selectionX1 <= elementX1 &&
isElementIntersectingFrame(element, associatedFrame, elementsMap) selectionY1 <= elementY1 &&
) { selectionX2 >= elementX2 &&
const frameAABB = getElementBounds(associatedFrame, elementsMap); selectionY2 >= elementY2
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 elementsInSelection = excludeElementsInFrames
? ([ ? excludeElementsInFramesFromSelection(elementsInSelection)
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 = elementsInSelection.filter((element) => { elementsInSelection = elementsInSelection.filter((element) => {
@@ -514,19 +288,3 @@ 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;
};
+16 -7
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { import {
elementCenterPoint,
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@@ -583,11 +583,7 @@ const getArrowheadShapes = (
export const generateLinearCollisionShape = ( export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap, ) => {
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -596,7 +592,20 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = elementCenterPoint(element, elementsMap); 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],
),
);
switch (element.type) { switch (element.type) {
case "line": case "line":
+9 -189
View File
@@ -111,67 +111,6 @@ export class Store {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY); 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. * Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
*/ */
@@ -267,7 +206,6 @@ export class Store {
public clear(): void { public clear(): void {
this.snapshot = StoreSnapshot.empty(); this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set(); this.scheduledMacroActions = new Set();
this.scheduledMicroActions = [];
} }
/** /**
@@ -307,24 +245,6 @@ 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. * Performs change calculation and emits an ephemeral increment.
* *
@@ -571,89 +491,6 @@ 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. * Represents a captured delta by the Store.
*/ */
@@ -662,7 +499,6 @@ export class StoreDelta {
public readonly id: string, public readonly id: string,
public readonly elements: ElementsDelta, public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta, public readonly appState: AppStateDelta,
public markers?: StoreDeltaMarkers,
) {} ) {}
/** /**
@@ -672,16 +508,12 @@ export class StoreDelta {
elements: ElementsDelta, elements: ElementsDelta,
appState: AppStateDelta, appState: AppStateDelta,
opts: { opts: {
id?: string; id: string;
markers?: StoreDeltaMarkers; } = {
} = {}, id: randomId(),
},
) { ) {
return new this( return new this(opts.id, elements, appState);
opts.id ?? randomId(),
elements,
appState,
cloneStoreDeltaMarkers(opts.markers),
);
} }
/** /**
@@ -706,12 +538,11 @@ export class StoreDelta {
* Restore a store delta instance from a DTO. * Restore a store delta instance from a DTO.
*/ */
public static restore(storeDeltaDTO: DTO<StoreDelta>) { public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState, markers } = storeDeltaDTO; const { id, elements, appState } = storeDeltaDTO;
return new this( return new this(
id, id,
ElementsDelta.restore(elements), ElementsDelta.restore(elements),
AppStateDelta.restore(appState), AppStateDelta.restore(appState),
cloneStoreDeltaMarkers(markers),
); );
} }
@@ -722,12 +553,11 @@ export class StoreDelta {
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta }, appState: { delta: appStateDelta },
markers,
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta); const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, appState, cloneStoreDeltaMarkers(markers)); return new this(id, elements, appState);
} }
/** /**
@@ -739,10 +569,8 @@ export class StoreDelta {
for (const delta of deltas) { for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements); aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState); aggregatedDelta.appState.squash(delta.appState);
mergeStoreDeltaMarkers(aggregatedDelta, delta.markers ?? {});
} }
pruneStoreDeltaMarkers(aggregatedDelta);
return aggregatedDelta; return aggregatedDelta;
} }
@@ -750,12 +578,7 @@ export class StoreDelta {
* Inverse store delta, creates new instance of `StoreDelta`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: StoreDelta) { public static inverse(delta: StoreDelta) {
const inversed = this.create( return this.create(delta.elements.inverse(), delta.appState.inverse());
delta.elements.inverse(),
delta.appState.inverse(),
{ markers: delta.markers },
);
return inversed;
} }
/** /**
@@ -791,7 +614,7 @@ export class StoreDelta {
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted", modifierOptions?: "deleted" | "inserted",
): StoreDelta { ): StoreDelta {
const nextDelta = this.create( return this.create(
delta.elements.applyLatestChanges( delta.elements.applyLatestChanges(
prevElements, prevElements,
nextElements, nextElements,
@@ -800,11 +623,8 @@ export class StoreDelta {
delta.appState, delta.appState,
{ {
id: delta.id, id: delta.id,
markers: delta.markers,
}, },
); );
pruneStoreDeltaMarkers(nextDelta);
return nextDelta;
} }
public static empty() { public static empty() {
+1 -3
View File
@@ -347,7 +347,6 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container, container,
index + 1, index + 1,
elementsMap,
); );
} }
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -442,8 +441,7 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}): element is ExcalidrawTextContainer => }) => VALID_CONTAINER_TYPES.has(element.type);
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+37 -207
View File
@@ -4,22 +4,6 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; 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 cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -374,10 +358,6 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * 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) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -390,120 +370,56 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * 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 = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): 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 // 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 // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return getHardLineBreaks(text); return text;
} }
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
let offset = 0; const originalLines = text.split("\n");
for (const originalLine of text.split("\n")) { for (const originalLine of originalLines) {
const originalLineWidth = getLineWidth(originalLine, font); const currentLineWidth = getLineWidth(originalLine, font);
if (originalLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push({ lines.push(originalLine);
text: originalLine, continue;
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
offset += originalLine.length + 1; const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
} }
return lines; return lines.join("\n");
}; };
/** /**
* Wraps a single hard line into one or more visual lines. * Wraps the original line into the lines based on the given width.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
lineStart: number, ): string[] => {
): WrappedTextLine[] => { const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
while (tokenIndex < tokens.length) { let iterator = tokenIterator.next();
const token = tokens[tokenIndex];
const tokenStart = tokenOffset; while (!iterator.done) {
const tokenEnd = tokenStart + token.length; const token = iterator.value;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -513,59 +429,37 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
tokenOffset = tokenEnd; iterator = tokenIterator.next();
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine.text; currentLine = trailingLine;
currentLineStart = trailingLine.start; currentLineWidth = getLineWidth(trailingLine, font);
currentLineEnd = trailingLine.end; iterator = tokenIterator.next();
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push( lines.push(currentLine.trimEnd());
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine( const trailingLine = trimLine(currentLine, font, maxWidth);
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -573,100 +467,59 @@ const wrapLine = (
}; };
/** /**
* Wraps a single word that could not be placed on an empty line as-is. * Wraps the word into the lines based on the given width.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
wordStart: number, ): Array<string> => {
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [ return [word];
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Trims trailing whitespace that is exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those 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 = ( const trimLine = (line: string, font: FontString, maxWidth: number) => {
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return { return line;
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -690,30 +543,7 @@ const trimLine = (
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return { return trimmedLine;
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),
};
}; };
/** /**
+4 -2
View File
@@ -124,7 +124,6 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/ */
export function deconstructLinearOrFreeDrawElement( export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0); const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -132,7 +131,10 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element, elementsMap); const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
+1 -3
View File
@@ -1,4 +1,4 @@
import { arrayToMap, reseed } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,7 +12,6 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
@@ -57,7 +56,6 @@ describe("hitElementItself cache", () => {
}); });
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
+18 -10
View File
@@ -330,22 +330,30 @@ describe("Cropping and other features", async () => {
const widthToHeightRatio = image.width / image.height; const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: [image], data: {
// @ts-ignore elements: [image],
appState: h.state, // @ts-ignore
files: h.app.files, appState: h.state,
exportPadding: 0, files: h.app.files,
},
config: {
padding: 0,
},
}); });
const exportedCanvasRatio = canvas.width / canvas.height; const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio); expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [image], data: {
// @ts-ignore elements: [image],
appState: h.state, // @ts-ignore
files: h.app.files, appState: h.state,
exportPadding: 0, files: h.app.files,
},
config: {
padding: 0,
},
}); });
const svgWidth = svg.getAttribute("width"); const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height"); const svgHeight = svg.getAttribute("height");
-140
View File
@@ -1,140 +0,0 @@
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 -70
View File
@@ -1,8 +1,4 @@
import { import { wrapText, parseTokens } from "../src/textWrapping";
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -106,71 +102,6 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); 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", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
@@ -1,145 +0,0 @@
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))
);
},
});
@@ -348,7 +348,9 @@ export const actionFinalize = register<FormData>({
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || (event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
@@ -191,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean), isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -209,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
elementPredicate === true isRelevantElement === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => elementPredicate(el)); : selectedElements.filter((el) => isRelevantElement(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -730,28 +730,9 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ app, updateData }) => (
const opacity = getFormValue( <Range updateData={updateData} app={app} testId="opacity" />
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"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown) => { predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,18 +26,13 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, targetElement) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === targetTextElement?.id && isTextElement(element)) { if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
-1
View File
@@ -34,7 +34,6 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export { export {
actionChangeProjectName, actionChangeProjectName,
-1
View File
@@ -114,7 +114,6 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
-2
View File
@@ -128,7 +128,6 @@ export const getDefaultAppState = (): Omit<
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
bindMode: "orbit", bindMode: "orbit",
boxSelectionMode: "contain",
}; };
}; };
@@ -194,7 +193,6 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, 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 }, bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
+60 -371
View File
@@ -27,7 +27,6 @@ import {
KEYS, KEYS,
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
@@ -258,7 +257,6 @@ import {
handleFocusPointPointerUp, handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag, maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight, getUncroppedWidthAndHeight,
getActiveTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -361,7 +359,6 @@ import { restoreAppState, restoreElements } from "../data/restore";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { History } from "../history"; import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import { TransactionManager } from "../transaction";
import { import {
calculateScrollCenter, calculateScrollCenter,
@@ -419,7 +416,6 @@ import {
import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { textWysiwyg } from "../wysiwyg/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars"; import { isOverScrollBars } from "../scene/scrollbars";
@@ -638,8 +634,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
public id: string; public id: string;
public transactionManager: TransactionManager; private store: Store;
public store: Store;
private history: History; private history: History;
public excalidrawContainerValue: { public excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
@@ -689,15 +684,9 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null; lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
// TODO this is a hack and we should ideally unify touch and pointer events
// and implement our own double click handling end-to-end (currently we're
// using a mix of native browser for click events and manual for touch -
// and browser doubleClick sucks to begin with)
lastPointerUpIsDoubleClick: boolean = false;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */ /** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
private lastCompletedCanvasClicks: { x: number; y: number }[] = [];
/** previous frame pointer coords */ /** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null; previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
@@ -834,8 +823,6 @@ class App extends React.Component<AppProps, AppState> {
this.store = new Store(this); this.store = new Store(this);
this.history = new History(this.store); this.history = new History(this.store);
this.transactionManager = new TransactionManager(this);
this.transactionManager.attachHistory(this.history);
this.excalidrawContainerValue = { this.excalidrawContainerValue = {
container: this.excalidrawContainerRef.current, container: this.excalidrawContainerRef.current,
@@ -843,6 +830,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
this.fonts = new Fonts(this.scene); this.fonts = new Fonts(this.scene);
this.history = new History(this.store);
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createUndoAction(this.history));
@@ -1260,26 +1248,6 @@ class App extends React.Component<AppProps, AppState> {
) as NullableGridSize; ) as NullableGridSize;
}; };
private getTextCreationGridPoint = (x: number, y: number) => {
const effectiveGridSize = this.getEffectiveGridSize();
if (effectiveGridSize === null) {
return null;
}
const getTextCreationGridCoordinate = (coordinate: number) => {
const topLeftGridPoint =
Math.floor(coordinate / effectiveGridSize) * effectiveGridSize;
return topLeftGridPoint;
};
return {
x: getTextCreationGridCoordinate(x),
y: getTextCreationGridCoordinate(y),
};
};
private getHTMLIFrameElement( private getHTMLIFrameElement(
element: ExcalidrawIframeLikeElement, element: ExcalidrawIframeLikeElement,
): HTMLIFrameElement | undefined { ): HTMLIFrameElement | undefined {
@@ -1470,21 +1438,6 @@ class App extends React.Component<AppProps, AppState> {
return true; return true;
} }
private isDoubleClick = (
lastPointerEvent:
| PointerEvent
| React.PointerEvent<HTMLElement>
| undefined
| null,
currentPointerEvent: PointerEvent | React.PointerEvent<HTMLElement>,
) => {
return (
lastPointerEvent != null &&
currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <=
TAP_TWICE_TIMEOUT
);
};
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
el: ExcalidrawIframeLikeElement | null, el: ExcalidrawIframeLikeElement | null,
event: React.PointerEvent<HTMLElement> | PointerEvent, event: React.PointerEvent<HTMLElement> | PointerEvent,
@@ -2368,7 +2321,6 @@ class App extends React.Component<AppProps, AppState> {
} }
handleCanvasRef={this.handleInteractiveCanvasRef} handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu} onContextMenu={this.handleCanvasContextMenu}
onClick={this.handleCanvasClick}
onPointerMove={this.handleCanvasPointerMove} onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp} onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer} onPointerCancel={this.removePointer}
@@ -2528,7 +2480,6 @@ class App extends React.Component<AppProps, AppState> {
const magicFrameChildren = getElementsOverlappingFrame( const magicFrameChildren = getElementsOverlappingFrame(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
magicFrame, magicFrame,
this.scene.getNonDeletedElementsMap(),
).filter((el) => !isMagicFrameElement(el)); ).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) { if (!magicFrameChildren.length) {
@@ -3623,14 +3574,10 @@ class App extends React.Component<AppProps, AppState> {
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.deselectElements(); this.deselectElements();
// @ts-ignore
this.handleCanvasDoubleClick({ this.handleCanvasDoubleClick({
clientX: touch.clientX, clientX: touch.clientX,
clientY: touch.clientY, clientY: touch.clientY,
type: "touch",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
}); });
} }
didTapTwice = false; didTapTwice = false;
@@ -4605,11 +4552,6 @@ class App extends React.Component<AppProps, AppState> {
); );
}; };
/** Creates a new transaction for batching mutations into a single undo entry. */
public createTransaction: TransactionManager["create"] = () => {
return this.transactionManager.create();
};
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>( public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
@@ -5675,14 +5617,8 @@ class App extends React.Component<AppProps, AppState> {
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
{ {
isExistingElement = false, isExistingElement = false,
initialCaretSceneCoords = null,
}: { }: {
isExistingElement?: boolean; isExistingElement?: boolean;
/**
* supply null if no caret positioning is desired, and instead
* text should be auto-selected
*/
initialCaretSceneCoords?: { x: number; y: number } | null;
}, },
) { ) {
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -5735,13 +5671,13 @@ class App extends React.Component<AppProps, AppState> {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted); updateElement(nextOriginalText, isDeleted);
// keyboard-submit keeps focus on the edited object. For bound text, keep // select the created text element only if submitting via keyboard
// the container selected even if the text becomes empty and is deleted. // (when submitting via click it should act as signal to deselect)
const elementIdToSelect = viaKeyboard if (!isDeleted && viaKeyboard) {
? element.containerId || (!isDeleted ? element.id : null) const elementIdToSelect = element.containerId
: null; ? element.containerId
: element.id;
if (elementIdToSelect) {
// needed to ensure state is updated before "finalize" action // needed to ensure state is updated before "finalize" action
// that's invoked on keyboard-submit as well // that's invoked on keyboard-submit as well
// TODO either move this into finalize as well, or handle all state // TODO either move this into finalize as well, or handle all state
@@ -5785,7 +5721,6 @@ class App extends React.Component<AppProps, AppState> {
element, element,
excalidrawContainer: this.excalidrawContainerRef.current, excalidrawContainer: this.excalidrawContainerRef.current,
app: this, app: this,
initialCaretSceneCoords,
// when text is selected, it's hard (at least on iOS) to re-position the // when text is selected, it's hard (at least on iOS) to re-position the
// caret (i.e. deselect). There's not much use for always selecting // caret (i.e. deselect). There's not much use for always selecting
// the text on edit anyway (and users can select-all from contextmenu // the text on edit anyway (and users can select-all from contextmenu
@@ -5809,68 +5744,6 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
private getSelectedTextElement(
container?: ExcalidrawTextContainer | null,
): NonDeleted<ExcalidrawTextElement> | null {
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length !== 1) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return selectedElement;
}
if (!container) {
return null;
}
return getBoundTextElement(
selectedElement,
this.scene.getNonDeletedElementsMap(),
);
}
private getSelectedTextEditingContainerAtPosition(
hitElement: NonDeletedExcalidrawElement | null,
sceneCoords: { x: number; y: number },
): ExcalidrawTextContainer | null | undefined {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
selectedElements.length !== 1 ||
!hitElement ||
hitElement.id !== selectedElements[0]!.id
) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return null;
}
if (!isValidTextContainer(selectedElement)) {
return undefined;
}
const textElement = this.getSelectedTextElement(selectedElement);
const hitTextElement = this.getTextElementAtPosition(
sceneCoords.x,
sceneCoords.y,
);
if (!textElement || hitTextElement?.id !== textElement.id) {
return undefined;
}
return selectedElement;
}
private getTextElementAtPosition( private getTextElementAtPosition(
x: number, x: number,
y: number, y: number,
@@ -5884,58 +5757,6 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
} }
private isHittingTextAutoResizeHandle = (
selectedElements: NonDeleted<ExcalidrawElement>[],
point: Readonly<{ x: number; y: number }>,
): boolean => {
const activeTextElement = getActiveTextElement(
selectedElements,
this.state,
);
if (
activeTextElement &&
!activeTextElement.isDeleted &&
!activeTextElement.autoResize &&
isPointHittingTextAutoResizeHandle(
point,
activeTextElement,
this.state.zoom.value,
this.editorInterface.formFactor,
)
) {
return true;
}
return false;
};
private handleTextAutoResizeHandlePointerDown = (
selectedElements: NonDeleted<ExcalidrawElement>[],
point: Readonly<{ x: number; y: number }>,
) => {
const activeTextElement = getActiveTextElement(
selectedElements,
this.state,
);
if (
!activeTextElement ||
!this.isHittingTextAutoResizeHandle(selectedElements, point)
) {
return false;
}
this.actionManager.executeAction(
actionTextAutoResize,
"ui",
// we need to pass down the element since it may already be deselected
// due to the pointerdown
activeTextElement,
);
this.resetCursor();
return true;
};
// NOTE: Hot path for hit testing, so avoid unnecessary computations // NOTE: Hot path for hit testing, so avoid unnecessary computations
private getElementAtPosition( private getElementAtPosition(
x: number, x: number,
@@ -6148,7 +5969,6 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter = true, insertAtParentCenter = true,
container, container,
autoEdit = true, autoEdit = true,
initialCaretSceneCoords,
}: { }: {
/** X position to insert text at */ /** X position to insert text at */
sceneX: number; sceneX: number;
@@ -6158,7 +5978,6 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter?: boolean; insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null; container?: ExcalidrawTextContainer | null;
autoEdit?: boolean; autoEdit?: boolean;
initialCaretSceneCoords?: { x: number; y: number };
}) => { }) => {
let shouldBindToContainer = false; let shouldBindToContainer = false;
@@ -6179,9 +5998,24 @@ class App extends React.Component<AppProps, AppState> {
shouldBindToContainer = true; shouldBindToContainer = true;
} }
} }
const existingTextElement = let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
this.getSelectedTextElement(container) ||
this.getTextElementAtPosition(sceneX, sceneY); const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length === 1) {
if (isTextElement(selectedElements[0])) {
existingTextElement = selectedElements[0];
} else if (container) {
existingTextElement = getBoundTextElement(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
);
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
const fontFamily = const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily; existingTextElement?.fontFamily || this.state.currentItemFontFamily;
@@ -6228,32 +6062,11 @@ class App extends React.Component<AppProps, AppState> {
y: sceneY, y: sceneY,
}); });
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
const newTextElementPosition = parentCenterPosition
? {
x: parentCenterPosition.elementCenterX,
y: parentCenterPosition.elementCenterY,
}
: !existingTextElement
? {
x: textCreationGridPoint?.x ?? sceneX,
y:
textCreationGridPoint === null
? // Free text starts from a point cursor, so center the first line box on it.
sceneY - getLineHeightInPx(fontSize, lineHeight) / 2
: textCreationGridPoint.y,
}
: {
x: sceneX,
y: sceneY,
};
const element = const element =
existingTextElement || existingTextElement ||
newTextElement({ newTextElement({
x: newTextElementPosition.x, x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX,
y: newTextElementPosition.y, y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY,
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle, fillStyle: this.state.currentItemFillStyle,
@@ -6303,9 +6116,6 @@ class App extends React.Component<AppProps, AppState> {
if (autoEdit || existingTextElement || container) { if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, { this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement, isExistingElement: !!existingTextElement,
initialCaretSceneCoords: existingTextElement
? initialCaretSceneCoords
: null,
}); });
} else { } else {
this.setState({ this.setState({
@@ -6331,48 +6141,9 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private shouldHandleBrowserCanvasDoubleClick = (type: string) => {
// TODO remove this once we consolidate double-click logic and handle
// ourselves for all event types together
if (type === "touch") {
return true;
}
if (this.lastCompletedCanvasClicks.length === 0) {
return true;
}
if (this.lastCompletedCanvasClicks.length < 2) {
return false;
}
const [firstClick, secondClick] = this.lastCompletedCanvasClicks;
return (
pointDistance(
pointFrom(firstClick.x, firstClick.y),
pointFrom(secondClick.x, secondClick.y),
) <= DOUBLE_TAP_POSITION_THRESHOLD
);
};
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (
event: Pick< event: React.MouseEvent<HTMLCanvasElement>,
React.MouseEvent<HTMLCanvasElement>,
| "type"
| "clientX"
| "clientY"
| "altKey"
| "ctrlKey"
| "metaKey"
| "shiftKey"
>,
) => { ) => {
if (
this.state.editingTextElement ||
!this.shouldHandleBrowserCanvasDoubleClick(event.type)
) {
return;
}
// case: double-clicking with arrow/line tool selected would both create // case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode // text and enter multiElement mode
if (this.state.multiElement) { if (this.state.multiElement) {
@@ -6560,21 +6331,6 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (event.button !== POINTER_BUTTON.MAIN) {
this.lastCompletedCanvasClicks = [];
return;
}
this.lastCompletedCanvasClicks = [
...this.lastCompletedCanvasClicks.slice(-1),
{
x: event.clientX,
y: event.clientY,
},
];
};
private getElementLinkAtPosition = ( private getElementLinkAtPosition = (
scenePointer: Readonly<{ x: number; y: number }>, scenePointer: Readonly<{ x: number; y: number }>,
hitElementMightBeLocked: NonDeletedExcalidrawElement | null, hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
@@ -7005,23 +6761,27 @@ class App extends React.Component<AppProps, AppState> {
}, },
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
const newLastIdx = multiElement.points.length - 1;
this.setState({ this.setState({
selectedLinearElement: { selectedLinearElement: {
...selectedLinearElement, ...selectedLinearElement,
selectedPointsIndices: selectedLinearElement.selectedPointsIndices selectedPointsIndices:
? [ selectedLinearElement.selectedPointsIndices?.includes(
...new Set( multiElement.points.length,
selectedLinearElement.selectedPointsIndices.map((idx) => )
Math.min(idx, newLastIdx), ? [
...selectedLinearElement.selectedPointsIndices.filter(
(idx) =>
idx !== multiElement.points.length &&
idx !== multiElement.points.length - 1,
), ),
), multiElement.points.length - 1,
] ]
: selectedLinearElement.selectedPointsIndices, : selectedLinearElement.selectedPointsIndices,
lastCommittedPoint: multiElement.points[newLastIdx], lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
initialState: { initialState: {
...selectedLinearElement.initialState, ...selectedLinearElement.initialState,
lastClickedPoint: newLastIdx, lastClickedPoint: multiElement.points.length - 1,
}, },
}, },
}); });
@@ -7107,12 +6867,6 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
return;
}
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!isOverScrollBar && !isOverScrollBar &&
@@ -7249,14 +7003,6 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas, this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
); );
} else if (
!event[KEYS.CTRL_OR_CMD] &&
this.isHittingCommonBoundingBoxOfSelectedElements(
scenePointer,
selectedElements,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") { } else if (this.state.openDialog?.name === "elementLinkSelector") {
@@ -7265,9 +7011,7 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if ( } else if (
// if using cmd/ctrl, we're not dragging // if using cmd/ctrl, we're not dragging
!event[KEYS.CTRL_OR_CMD] && !event[KEYS.CTRL_OR_CMD]
// editing text -> don't show move cursor when hovering over its bbox
hitElement?.id !== this.state.editingTextElement?.id
) { ) {
if ( if (
(hitElement || (hitElement ||
@@ -7488,8 +7232,6 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const selectedElements = this.scene.getSelectedElements(this.state);
// If Ctrl is not held, ensure isBindingEnabled reflects the user preference. // If Ctrl is not held, ensure isBindingEnabled reflects the user preference.
if (!event.ctrlKey) { if (!event.ctrlKey) {
const preferenceEnabled = this.state.bindingPreference === "enabled"; const preferenceEnabled = this.state.bindingPreference === "enabled";
@@ -7713,15 +7455,6 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
}); });
if (
this.handleTextAutoResizeHandlePointerDown(
selectedElements,
pointerDownState.origin,
)
) {
return;
}
if (this.handleDraggingScrollBar(event, pointerDownState)) { if (this.handleDraggingScrollBar(event, pointerDownState)) {
return; return;
} }
@@ -7748,24 +7481,17 @@ class App extends React.Component<AppProps, AppState> {
const hitSelectedElement = const hitSelectedElement =
pointerDownState.hit.element && pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element); this.isASelectedElement(pointerDownState.hit.element);
const shouldForceLassoReselect =
event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
!pointerDownState.resize.handleType;
const shouldStartLassoSelection =
shouldForceLassoReselect ||
(!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement);
if (shouldStartLassoSelection) { if (
if (!this.lassoTrail.hasCurrentTrail) { !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
this.lassoTrail.startPath( !pointerDownState.resize.handleType &&
pointerDownState.origin.x, !hitSelectedElement
pointerDownState.origin.y, ) {
event.shiftKey, this.lassoTrail.startPath(
); pointerDownState.origin.x,
} pointerDownState.origin.y,
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down // block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately) // (on mobile or tablet, we want to allow user to drag immediately)
@@ -7954,10 +7680,6 @@ class App extends React.Component<AppProps, AppState> {
} }
this.removePointer(event); this.removePointer(event);
this.lastPointerUpIsDoubleClick = this.isDoubleClick(
this.lastPointerUpEvent,
event,
);
this.lastPointerUpEvent = event; this.lastPointerUpEvent = event;
if (!event.ctrlKey) { if (!event.ctrlKey) {
@@ -8754,14 +8476,12 @@ class App extends React.Component<AppProps, AppState> {
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value, DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1, 1,
); );
const boundsPadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return ( return (
point.x > x1 - boundsPadding - threshold && point.x > x1 - threshold &&
point.x < x2 + boundsPadding + threshold && point.x < x2 + threshold &&
point.y > y1 - boundsPadding - threshold && point.y > y1 - threshold &&
point.y < y2 + boundsPadding + threshold point.y < y2 + threshold
); );
} }
@@ -8796,7 +8516,6 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter: !event.altKey, insertAtParentCenter: !event.altKey,
container, container,
autoEdit: false, autoEdit: false,
initialCaretSceneCoords: { x: sceneX, y: sceneY },
}); });
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
@@ -10294,7 +10013,6 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false, false,
this.state.boxSelectionMode,
) )
: []; : [];
@@ -11283,35 +11001,6 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const selectedTextEditingContainer =
this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords);
if (
activeTool.type === this.state.preferredSelectionTool.type &&
!this.state.editingTextElement &&
!pointerDownState.drag.hasOccurred &&
!pointerDownState.hit.wasAddedToSelection &&
!childEvent.shiftKey &&
!childEvent[KEYS.CTRL_OR_CMD] &&
!childEvent.altKey &&
childEvent.pointerType !== "touch" &&
hitElement &&
((isTextElement(hitElement) &&
this.state.selectedElementIds[hitElement.id] &&
this.scene.getSelectedElements(this.state).length === 1) ||
selectedTextEditingContainer)
) {
this.startTextEditing({
sceneX: sceneCoords.x,
sceneY: sceneCoords.y,
container: selectedTextEditingContainer,
initialCaretSceneCoords: this.lastPointerUpIsDoubleClick
? undefined
: sceneCoords,
});
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) { if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
@@ -46,7 +46,6 @@ import {
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import type { JSX } from "react"; import type { JSX } from "react";
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
export interface FontDescriptor { export interface FontDescriptor {
value: number; value: number;
@@ -87,15 +86,6 @@ 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( export const FontPickerList = React.memo(
({ ({
selectedFontFamily, selectedFontFamily,
@@ -124,7 +114,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: getFontFamilyIcon(familyId), icon: getFontFamilyIcon(familyId),
text: getFontFamilyLabel(familyId, fontFaces), text: fontFaces[0]?.fontFace?.family ?? "Unknown",
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -1,4 +1,3 @@
import { exportToCanvas } from "@excalidraw/utils/export";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
@@ -6,6 +5,7 @@ import {
EXPORT_IMAGE_TYPES, EXPORT_IMAGE_TYPES,
isFirefox, isFirefox,
EXPORT_SCALES, EXPORT_SCALES,
THEME,
cloneJSON, cloneJSON,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -26,6 +26,7 @@ import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -128,19 +129,26 @@ const ImageExportModal = ({
}; };
exportToCanvas({ exportToCanvas({
elements: exportedElements, data: {
appState: { elements: exportedElements,
...appStateSnapshot, appState: {
name: projectName, ...appStateSnapshot,
exportBackground: exportWithBackground, name: projectName,
exportWithDarkMode, exportBackground: exportWithBackground,
exportScale, exportScale,
exportEmbedScene: embedScene, exportEmbedScene: embedScene,
},
files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
canvasBackgroundColor: exportWithBackground
? appStateSnapshot.viewBackgroundColor
: false,
}, },
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}) })
.then(async (canvas) => { .then(async (canvas) => {
if (isStaleRequest()) { if (isStaleRequest()) {
@@ -72,18 +72,20 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg( svg = await exportToSvg({
elements, data: {
{ elements,
exportBackground: false, appState: {
viewBackgroundColor: "#fff", exportBackground: false,
exportWithDarkMode: theme === "dark", viewBackgroundColor: "#fff",
},
files: null,
}, },
null, // files config: {
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -134,18 +136,20 @@ const PlainTextPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
const svg = await exportToSvg( const svg = await exportToSvg({
[textElement], data: {
{ elements: [textElement],
exportBackground: false, appState: {
viewBackgroundColor: "#fff", exportBackground: false,
exportWithDarkMode: theme === "dark", viewBackgroundColor: "#fff",
},
files: null,
}, },
null, config: {
{
skipInliningFonts: true, skipInliningFonts: true,
theme,
}, },
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -1,4 +1,3 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
@@ -13,6 +12,7 @@ import {
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
@@ -63,9 +63,14 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) { for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({ const itemCanvas = await exportToCanvas({
elements: item.elements, data: {
files: null, elements: item.elements,
maxWidthOrHeight: BOX_SIZE, files: null,
appState: {},
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
}); });
const { width, height } = itemCanvas; const { width, height } = itemCanvas;
@@ -127,14 +132,18 @@ const SingleLibraryItem = ({
} }
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: libItem.elements, data: {
appState: { elements: libItem.elements,
...appState, appState: {
viewBackgroundColor: "#fff", ...appState,
exportBackground: true, viewBackgroundColor: "#fff",
exportBackground: true,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -26,16 +26,13 @@
background: var(--RadioGroup-background); background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border); border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice { &__choice {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 20px; width: 32px;
height: 24px; height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off); color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off); background: var(--RadioGroup-choice-background-off);
+33 -37
View File
@@ -1,78 +1,74 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss"; import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = { export type RangeProps = {
label: React.ReactNode; updateData: (value: number) => void;
value: number; app: AppClassProperties;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ export const Range = ({ updateData, app, testId }: RangeProps) => {
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null); const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(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(() => { useEffect(() => {
if (rangeRef.current && valueRef.current) { if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current; const rangeElement = rangeRef.current;
const valueElement = valueRef.current; const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth; const inputWidth = rangeElement.offsetWidth;
const thumbWidth = const thumbWidth = 15; // 15 is the width of the thumb
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`; valueElement.style.left = `${position}px`;
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%)`; 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%)`;
} }
}, [max, min, value]); }, [value]);
return ( return (
<label className="control-label"> <label className="control-label">
{label} {t("labels.opacity")}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonValue ["--color-slider-track" as string]: hasCommonOpacity
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min={min} min="0"
max={max} max="100"
step={step} step="10"
onChange={(event) => { onChange={(event) => {
onChange(+event.target.value); updateData(+event.target.value);
}} }}
value={value} value={value}
className="range-input" className="range-input"
data-testid={testId} data-testid={testId}
/> />
<div className="value-bubble" ref={valueRef}> <div className="value-bubble" ref={valueRef}>
{value !== min ? value : null} {value !== 0 ? value : null}
</div> </div>
<div className="zero-label">{minLabel}</div> <div className="zero-label">0</div>
</div> </div>
</label> </label>
); );
@@ -361,10 +361,12 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(); const editor = await getTextEditor();
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
Keyboard.exitTextEditor(editor); act(() => {
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
API.setSelectedElements([text]); mouse.clickOn(text);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -750,7 +752,7 @@ describe("frame resizing behavior", () => {
x: 0, x: 0,
y: 0, y: 0,
width: 100, width: 100,
height: 103, height: 100,
}); });
// Create a rectangle outside the frame // Create a rectangle outside the frame
@@ -1,8 +1,4 @@
import { import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element"; import { convertToExcalidrawElements } from "@excalidraw/element";
@@ -105,14 +101,16 @@ export const convertMermaidToExcalidraw = async ({
}; };
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: data.current.elements, data: {
files: data.current.files, elements: data.current.elements,
exportPadding: DEFAULT_EXPORT_PADDING, files: data.current.files,
maxWidthOrHeight: },
Math.max(parent.offsetWidth, parent.offsetHeight) * config: {
window.devicePixelRatio, padding: DEFAULT_EXPORT_PADDING,
appState: { maxWidthOrHeight:
exportWithDarkMode: theme === THEME.DARK, Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
theme,
}, },
}); });
@@ -54,7 +54,6 @@ type InteractiveCanvasProps = {
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"], DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined undefined
>; >;
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
onPointerMove: Exclude< onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"], DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined undefined
@@ -214,7 +213,6 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
ref={props.handleCanvasRef} ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel} onPointerCancel={props.onPointerCancel}
@@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
max-width: 20rem; max-width: 16rem;
z-index: 1; z-index: 1;
&--placement-top { &--placement-top {
@@ -1,5 +1,4 @@
import { useEditorInterface } from "../App"; import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup"; import { RadioGroup } from "../RadioGroup";
type Props<T> = { type Props<T> = {
@@ -13,7 +12,6 @@ type Props<T> = {
onChange: (value: T) => void; onChange: (value: T) => void;
children: React.ReactNode; children: React.ReactNode;
name: string; name: string;
icon?: React.ReactNode;
}; };
const DropdownMenuItemContentRadio = <T,>({ const DropdownMenuItemContentRadio = <T,>({
@@ -23,17 +21,13 @@ const DropdownMenuItemContentRadio = <T,>({
choices, choices,
children, children,
name, name,
icon,
}: Props<T>) => { }: Props<T>) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
return ( return (
<> <>
<div className="dropdown-menu-item-base dropdown-menu-item-bare"> <div className="dropdown-menu-item-base dropdown-menu-item-bare">
{icon && <div className="dropdown-menu-item__icon">{icon}</div>} <label className="dropdown-menu-item__text">{children}</label>
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -39,13 +39,7 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -433,39 +427,6 @@ 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 PreferencesToggleSnapModeItem = () => {
const { t } = useI18n(); const { t } = useI18n();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@@ -607,7 +568,6 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu"> <DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || ( {children || (
<> <>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem /> <PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem /> <PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem /> <PreferencesToggleGridModeItem />
@@ -625,7 +585,6 @@ export const Preferences = ({
}; };
Preferences.ToggleToolLock = PreferencesToggleToolLockItem; Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+36 -24
View File
@@ -4,9 +4,9 @@ import {
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
THEME,
cloneJSON, cloneJSON,
SVG_DOCUMENT_PREAMBLE, SVG_DOCUMENT_PREAMBLE,
arrayToMap,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@@ -50,7 +50,6 @@ export const prepareElementsForExport = (
exportSelectionOnly: boolean, exportSelectionOnly: boolean,
) => { ) => {
elements = getNonDeletedElements(elements); elements = getNonDeletedElements(elements);
const elementsMap = arrayToMap(elements);
const isExportingSelection = const isExportingSelection =
exportSelectionOnly && exportSelectionOnly &&
@@ -73,11 +72,7 @@ export const prepareElementsForExport = (
isFrameLikeElement(exportedElements[0]) isFrameLikeElement(exportedElements[0])
) { ) {
exportingFrame = exportedElements[0]; exportingFrame = exportedElements[0];
exportedElements = getElementsOverlappingFrame( exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
elements,
exportingFrame,
elementsMap,
);
} else if (exportedElements.length > 1) { } else if (exportedElements.length > 1) {
exportedElements = getSelectedElements( exportedElements = getSelectedElements(
elements, elements,
@@ -121,20 +116,29 @@ export const exportCanvas = async (
if (elements.length === 0) { if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg( const svgPromise = exportToSvg({
elements, data: {
{ elements,
exportBackground, appState: {
exportWithDarkMode: appState.exportWithDarkMode, ...appState,
viewBackgroundColor, exportBackground,
exportPadding, exportEmbedScene: appState.exportEmbedScene && type === "svg",
exportScale: appState.exportScale, },
exportEmbedScene: appState.exportEmbedScene && type === "svg", files,
}, },
files, config: {
{ exportingFrame }, padding: exportPadding,
); exportingFrame,
theme,
canvasBackgroundColor: exportBackground
? viewBackgroundColor
: "transparent",
},
});
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
@@ -164,11 +168,19 @@ export const exportCanvas = async (
} }
} }
const tempCanvas = exportToCanvas(elements, appState, files, { const tempCanvas = exportToCanvas({
exportBackground, data: {
viewBackgroundColor, elements,
exportPadding, appState,
exportingFrame, files,
},
config: {
canvasBackgroundColor: exportBackground ? viewBackgroundColor : false,
padding: exportPadding,
theme,
scale: appState.exportScale,
exportingFrame,
},
}); });
if (type === "png") { if (type === "png") {
+4
View File
@@ -20,6 +20,10 @@ export const resaveAsImageWithScene = async (
) => { ) => {
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
+1 -9
View File
@@ -251,9 +251,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
} }
console.error( console.error(`could not repair binding for element`);
`Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`,
);
} catch (error) { } catch (error) {
console.error("Error repairing binding:", error); console.error("Error repairing binding:", error);
} }
@@ -936,12 +934,6 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+10 -60
View File
@@ -87,22 +87,10 @@ 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 { export class History {
public readonly onHistoryChangedEmitter = new Emitter< public readonly onHistoryChangedEmitter = new Emitter<
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
private readonly onBeforeRecordEmitter = new Emitter<[StoreDelta]>();
private effectiveDeltaResolver: HistoryEffectiveDeltaResolver | null = null;
public readonly undoStack: HistoryDelta[] = []; public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryDelta[] = []; public readonly redoStack: HistoryDelta[] = [];
@@ -117,20 +105,6 @@ export class History {
constructor(private readonly store: Store) {} 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() { public clear() {
this.undoStack.length = 0; this.undoStack.length = 0;
this.redoStack.length = 0; this.redoStack.length = 0;
@@ -145,8 +119,6 @@ export class History {
return; return;
} }
this.onBeforeRecordEmitter.trigger(delta);
// construct history entry, so once it's emitted, it's not recorded again // construct history entry, so once it's emitted, it's not recorded again
const historyDelta = HistoryDelta.inverse(delta); const historyDelta = HistoryDelta.inverse(delta);
@@ -159,14 +131,15 @@ export class History {
this.redoStack.length = 0; this.redoStack.length = 0;
} }
this.emitHistoryChanged(); this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
} }
public undo(elements: SceneElementsMap, appState: AppState) { public undo(elements: SceneElementsMap, appState: AppState) {
return this.perform( return this.perform(
elements, elements,
appState, appState,
"undo",
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryDelta) => History.push(this.redoStack, entry), (entry: HistoryDelta) => History.push(this.redoStack, entry),
); );
@@ -176,7 +149,6 @@ export class History {
return this.perform( return this.perform(
elements, elements,
appState, appState,
"redo",
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryDelta) => History.push(this.undoStack, entry), (entry: HistoryDelta) => History.push(this.undoStack, entry),
); );
@@ -185,7 +157,6 @@ export class History {
private perform( private perform(
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
direction: HistoryPerformDirection,
pop: () => HistoryDelta | null, pop: () => HistoryDelta | null,
push: (entry: HistoryDelta) => void, push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
@@ -206,15 +177,9 @@ export class History {
// iterate through the history entries in case they result in no visible changes // iterate through the history entries in case they result in no visible changes
while (historyDelta) { while (historyDelta) {
// Roundtrip invariant: whichever delta we execute must be the one that
// continues through applyLatestChanges -> inverse -> opposite stack.
let entryToPush = historyDelta;
try { try {
const effectiveDelta = this.resolveEffectiveDelta(historyDelta, {
direction,
});
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot); historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
const prevElements = prevSnapshot.elements; const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone( const nextSnapshot = prevSnapshot.maybeClone(
@@ -225,7 +190,7 @@ export class History {
const change = StoreChange.create(prevSnapshot, nextSnapshot); const change = StoreChange.create(prevSnapshot, nextSnapshot);
const delta = HistoryDelta.applyLatestChanges( const delta = HistoryDelta.applyLatestChanges(
effectiveDelta, historyDelta,
prevElements, prevElements,
nextElements, nextElements,
); );
@@ -238,12 +203,12 @@ export class History {
delta, delta,
}); });
entryToPush = delta; historyDelta = delta;
} }
prevSnapshot = nextSnapshot; prevSnapshot = nextSnapshot;
} finally { } finally {
push(entryToPush); push(historyDelta);
} }
if (containsVisibleChange) { if (containsVisibleChange) {
@@ -257,16 +222,12 @@ export class History {
} finally { } finally {
// trigger the history change event before returning completely // trigger the history change event before returning completely
// also trigger it just once, no need doing so on each entry // also trigger it just once, no need doing so on each entry
this.emitHistoryChanged(); this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
} }
} }
private emitHistoryChanged() {
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
private static pop(stack: HistoryDelta[]): HistoryDelta | null { private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) { if (!stack.length) {
return null; return null;
@@ -285,15 +246,4 @@ export class History {
const inversedEntry = HistoryDelta.inverse(entry); const inversedEntry = HistoryDelta.inverse(entry);
return stack.push(inversedEntry); return stack.push(inversedEntry);
} }
private resolveEffectiveDelta(
delta: HistoryDelta,
context: HistoryEffectiveDeltaResolverContext,
): HistoryDelta {
if (!this.effectiveDeltaResolver) {
return delta;
}
return this.effectiveDeltaResolver(delta, context);
}
} }
+12 -10
View File
@@ -1,8 +1,9 @@
import { exportToSvg } from "@excalidraw/utils/export";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "@excalidraw/common"; import { COLOR_PALETTE } from "@excalidraw/common";
import { exportToSvg } from "../scene/export";
import { atom, useAtom } from "../editor-jotai"; import { atom, useAtom } from "../editor-jotai";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
@@ -12,17 +13,18 @@ export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
export const libraryItemSvgsCache = atom<SvgCache>(new Map()); export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
// TODO should pass theme (appState.exportWithDark) - we're still using
// CSS filter here
return await exportToSvg({ return await exportToSvg({
elements, data: {
appState: { elements,
exportBackground: false, appState: {
viewBackgroundColor: COLOR_PALETTE.white, exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
},
config: {
skipInliningFonts: true,
}, },
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
}); });
}; };
+19 -23
View File
@@ -1,4 +1,3 @@
import { getDefaultAppState } from "./appState";
import { exportToCanvas } from "./scene/export"; import { exportToCanvas } from "./scene/export";
const fs = require("fs"); const fs = require("fs");
@@ -59,26 +58,23 @@ const elements = [
registerFont("./public/Virgil.woff2", { family: "Virgil" }); registerFont("./public/Virgil.woff2", { family: "Virgil" });
registerFont("./public/Cascadia.woff2", { family: "Cascadia" }); registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
const canvas = exportToCanvas( (async () => {
elements as any, const canvas = await exportToCanvas({
{ data: {
...getDefaultAppState(), elements: elements as any,
offsetTop: 0, appState: {},
offsetLeft: 0, files: {},
width: 0, },
height: 0, config: {
}, canvasBackgroundColor: "#ffffff",
{}, // files createCanvas,
{ },
exportBackground: true, });
viewBackgroundColor: "#ffffff",
},
createCanvas,
);
const out = fs.createWriteStream("test.png"); const out = fs.createWriteStream("test.png");
const stream = (canvas as any).createPNGStream(); const stream = (canvas as any).createPNGStream();
stream.pipe(out); stream.pipe(out);
out.on("finish", () => { out.on("finish", () => {
console.info("test.png was created."); console.info("test.png was created.");
}); });
})();
+3 -3
View File
@@ -304,7 +304,9 @@ export {
exportToBlob, exportToBlob,
exportToSvg, exportToSvg,
exportToClipboard, exportToClipboard,
} from "@excalidraw/utils/export"; } from "./scene/export";
export type { ExportSceneData, ExportSceneConfig } from "./scene/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export { export {
@@ -382,8 +384,6 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC
export { getDataURL } from "./data/blob"; export { getDataURL } from "./data/blob";
export { isElementLink } from "@excalidraw/element"; export { isElementLink } from "@excalidraw/element";
export { Fonts } from "./fonts/Fonts";
export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element";
export { CommandPalette } from "./components/CommandPalette/CommandPalette"; export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+1 -4
View File
@@ -185,9 +185,6 @@
"shapeSwitch": "Switch shape", "shapeSwitch": "Switch shape",
"preferences": "Preferences", "preferences": "Preferences",
"preferences_toolLock": "Tool lock", "preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding", "arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints" "midpointSnapping": "Snap to midpoints"
}, },
@@ -664,7 +661,7 @@
"placeholder": { "placeholder": {
"title": "Let's design your diagram", "title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.", "description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, Class, State, and Entity Relationship diagrams." "hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
}, },
"preview": "Preview", "preview": "Preview",
"insert": "Insert", "insert": "Insert",
+2 -2
View File
@@ -83,13 +83,13 @@
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0", "@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0",
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.2.2", "@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@lezer/highlight": "^1.0.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
@@ -41,7 +41,6 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element";
@@ -59,8 +58,6 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -89,10 +86,6 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import {
getTextAutoResizeHandle,
getTextBoxPadding,
} from "../textAutoResizeHandle";
import { import {
bootstrapCanvas, bootstrapCanvas,
@@ -1156,7 +1149,6 @@ const renderLinearPointHandles = (
points[idx], points[idx],
idx, idx,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
renderSingleLinearPoint( renderSingleLinearPoint(
@@ -1497,58 +1489,21 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = getTextBoxPadding(appState.zoom.value); const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
const width = text.width + padding * 2; const width = text.width + padding * 2;
const height = text.height + padding * 2; const height = text.height + padding * 2;
const cx = text.x + text.width / 2; const cx = text.x + width / 2;
const cy = text.y + text.height / 2; const cy = text.y + height / 2;
const shiftX = -(text.width / 2 + padding); const shiftX = -(width / 2 + padding);
const shiftY = -(text.height / 2 + padding); const shiftY = -(height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY); context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle); context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value; context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor; context.strokeStyle = selectionColor;
context.globalAlpha = 0.5;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeRect(shiftX, shiftY, width, height); context.strokeRect(shiftX, shiftY, width, height);
context.restore(); 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 = ({ const _renderInteractiveScene = ({
app, app,
canvas, canvas,
@@ -1629,19 +1584,10 @@ const _renderInteractiveScene = ({
} }
} }
const activeTextElement = getActiveTextElement(selectedElements, appState); if (
appState.editingTextElement &&
if (activeTextElement && !activeTextElement.autoResize) { isTextElement(appState.editingTextElement)
renderResetAutoResizeHandle( ) {
activeTextElement,
context,
appState,
renderConfig.selectionColor,
editorInterface.formFactor,
);
}
if (appState.editingTextElement) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
+673 -128
View File
@@ -1,13 +1,13 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
DEFAULT_EXPORT_PADDING,
FRAME_STYLE, FRAME_STYLE,
FONT_FAMILY, FONT_FAMILY,
SVG_NS, SVG_NS,
THEME, THEME,
MIME_TYPES, MIME_TYPES,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
COLOR_WHITE,
arrayToMap, arrayToMap,
distance, distance,
getFontString, getFontString,
@@ -47,20 +47,33 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
Theme,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode"; import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
import { restoreAppState } from "../data/restore";
import { encodePngMetadata } from "../data/image";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { renderSceneToSvg } from "../renderer/staticSvgScene";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "../clipboard";
import type { RenderableElementsMap } from "./types"; import type { RenderableElementsMap } from "./types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
// Default minimum export size in pixels
const DEFAULT_SMALLEST_EXPORT_SIZE = 20;
const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) { if (element.width <= maxWidth) {
@@ -157,11 +170,7 @@ const prepareElementsForRender = ({
let nextElements: readonly ExcalidrawElement[]; let nextElements: readonly ExcalidrawElement[];
if (exportingFrame) { if (exportingFrame) {
nextElements = getElementsOverlappingFrame( nextElements = getElementsOverlappingFrame(elements, exportingFrame);
elements,
exportingFrame,
arrayToMap(elements),
);
} else if (frameRendering.enabled && frameRendering.name) { } else if (frameRendering.enabled && frameRendering.name) {
nextElements = addFrameLabelsAsTextElements(elements, { nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode, exportWithDarkMode,
@@ -173,36 +182,205 @@ const prepareElementsForRender = ({
return nextElements; return nextElements;
}; };
export const exportToCanvas = async ( // ---------------------------------------------------------------------------
elements: readonly NonDeletedExcalidrawElement[], // Types for the new API
appState: AppState, // ---------------------------------------------------------------------------
files: BinaryFiles,
{ export type ExportSceneData = {
exportBackground, elements: readonly NonDeletedExcalidrawElement[];
exportPadding = DEFAULT_EXPORT_PADDING, appState?: Partial<
viewBackgroundColor, Omit<AppState, "offsetTop" | "offsetLeft" | "exportWithDarkMode">
exportingFrame, >;
}: { files: BinaryFiles | null;
exportBackground: boolean; };
exportPadding?: number;
viewBackgroundColor: string; export type ExportSceneConfig = {
exportingFrame?: ExcalidrawFrameLikeElement | null; theme?: Theme;
}, /**
createCanvas: ( * Canvas background. Valid values are:
*
* - `undefined` - the background of "appState.viewBackgroundColor" is used.
* - `false` - no background is used (set to "transparent").
* - `string` - should be a valid CSS color.
*
* @default undefined
*/
canvasBackgroundColor?: string | false;
/**
* Canvas padding in pixels. Affected by `scale`.
*
* When `fit` is set to `none`, padding is added to the content bounding box
* (including if you set `width` or `height` or `maxWidthOrHeight` or
* `widthOrHeight`).
*
* When `fit` set to `contain`, padding is subtracted from the content
* bounding box (ensuring the size doesn't exceed the supplied values, with
* the exeception of using alongside `scale` as noted above), and the padding
* serves as a minimum distance between the content and the canvas edges, as
* it may exceed the supplied padding value from one side or the other in
* order to maintain the aspect ratio. It is recommended to set `position`
* to `center` when using `fit=contain`.
*
* When `fit` is set to `none` and either `width` or `height` or
* `maxWidthOrHeight` is set, padding is simply adding to the bounding box
* and the content may overflow the canvas, thus right or bottom padding
* may be ignored.
*
* @default 0
*/
padding?: number;
// -------------------------------------------------------------------------
/**
* Makes sure the canvas content fits into a frame of width/height no larger
* than this value, while maintaining the aspect ratio.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
maxWidthOrHeight?: number;
/**
* Scale the canvas content to be excatly this many pixels wide/tall,
* maintaining the aspect ratio.
*
* Cannot be used in conjunction with `maxWidthOrHeight`.
*
* Final dimensions can get smaller/larger if used in conjunction with
* `scale`.
*/
widthOrHeight?: number;
// -------------------------------------------------------------------------
/**
* Width of the frame. Supply `x` or `y` if you want to ofsset the canvas
* content.
*
* If `width` omitted but `height` supplied, `width` is calculated from the
* the content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box width when both `width` and `height`
* are omitted.
*/
width?: number;
/**
* Height of the frame.
*
* If `height` omitted but `width` supplied, `height` is calculated from the
* content's bounding box to preserve the aspect ratio.
*
* Defaults to the content bounding box height when both `width` and `height`
* are omitted.
*/
height?: number;
/**
* Left canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `x` postion of the content bounding box.
*/
x?: number;
/**
* Top canvas offset. By default the coordinate is relative to the canvas.
* You can switch to content coordinates by setting `origin` to `content`.
*
* Defaults to the `y` postion of the content bounding box.
*/
y?: number;
/**
* Indicates the coordinate system of the `x` and `y` values.
*
* - `canvas` - `x` and `y` are relative to the canvas [0, 0] position.
* - `content` - `x` and `y` are relative to the content bounding box.
*
* @default "canvas"
*/
origin?: "canvas" | "content";
/**
* If dimensions specified and `x` and `y` are not specified, this indicates
* how the canvas should be scaled.
*
* Behavior aligns with the `object-fit` CSS property.
*
* - `none` - no scaling.
* - `contain` - scale to fit the frame. Includes `padding`.
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `fit` is ignored.
*
* @default "contain" unless `width`, `height`, `maxWidthOrHeight`, or
* `widthOrHeight` is specified in which case `none` is the default (can be
* changed). If `x` or `y` are specified, `none` is forced.
*/
fit?: "none" | "contain";
/**
* When either `x` or `y` are not specified, indicates how the canvas should
* be aligned on the respective axis.
*
* - `none` - canvas aligned to top left.
* - `center` - canvas is centered on the axis which is not specified
* (or both).
*
* If `maxWidthOrHeight` or `widthOrHeight` is set, `position` is ignored.
*
* @default "center"
*/
position?: "center" | "topLeft";
// -------------------------------------------------------------------------
/**
* A multiplier to increase/decrease the frame dimensions
* (content resolution).
*
* For example, if your canvas is 300x150 and you set scale to 2, the
* resulting size will be 600x300.
*
* @default 1
*/
scale?: number;
/**
* If you need to suply your own canvas, e.g. in test environments or in
* Node.js.
*
* Do not set `canvas.width/height` or modify the canvas context as that's
* handled by Excalidraw.
*
* Defaults to `document.createElement("canvas")`.
*/
createCanvas?: () => HTMLCanvasElement;
/**
* If you want to supply `width`/`height` dynamically (or derive from the
* content bounding box), you can use this function.
*
* Ignored if `maxWidthOrHeight`, `width`, or `height` is set.
*/
getDimensions?: (
width: number, width: number,
height: number, height: number,
) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => { ) => { width: number; height: number; scale?: number };
const canvas = document.createElement("canvas");
canvas.width = width * appState.exportScale; exportingFrame?: ExcalidrawFrameLikeElement | null;
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale }; loadFonts?: () => Promise<void>;
}, };
loadFonts: () => Promise<void> = async () => {
await Fonts.loadElementsFonts(elements); // ---------------------------------------------------------------------------
}, // Internal helper to configure export dimensions
) => { // ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
await loadFonts(); const configExportDimension = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
// clone
const cfg = Object.assign({}, config);
const { exportingFrame } = cfg;
const elements = data.elements;
// initialize defaults
// ---------------------------------------------------------------------------
const appState = restoreAppState(data.appState, null);
const frameRendering = getFrameRenderingConfig( const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null, exportingFrame ?? null,
@@ -222,26 +400,255 @@ export const exportToCanvas = async (
}); });
if (exportingFrame) { if (exportingFrame) {
exportPadding = 0; cfg.padding = 0;
} }
const [minX, minY, width, height] = getCanvasSize( cfg.fit =
cfg.fit ??
(cfg.width != null ||
cfg.height != null ||
cfg.maxWidthOrHeight != null ||
cfg.widthOrHeight != null
? "contain"
: "none");
cfg.padding = cfg.padding ?? 0;
cfg.scale = cfg.scale ?? 1;
cfg.origin = cfg.origin ?? "canvas";
cfg.position = cfg.position ?? "center";
if (cfg.maxWidthOrHeight != null && cfg.widthOrHeight != null) {
if (!import.meta.env.PROD) {
console.warn("`maxWidthOrHeight` is ignored when `widthOrHeight` is set");
}
cfg.maxWidthOrHeight = undefined;
}
if (
(cfg.maxWidthOrHeight != null || cfg.width != null || cfg.height != null) &&
cfg.getDimensions
) {
if (!import.meta.env.PROD) {
console.warn(
"`getDimensions` is ignored when `width`, `height`, or `maxWidthOrHeight` is set",
);
}
cfg.getDimensions = undefined;
}
// ---------------------------------------------------------------------------
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
if (cfg.loadFonts) {
await cfg.loadFonts();
} else {
await Fonts.loadElementsFonts(elements);
}
// value used to scale the canvas context. By default, we use this to
// make the canvas fit into the frame (e.g. for `cfg.fit` set to `contain`).
// If `cfg.scale` is set, we multiply the resulting canvasScale by it to
// scale the output further.
let exportScale = 1;
const origCanvasSize = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
); );
const { canvas, scale = 1 } = createCanvas(width, height); // variables for original content bounding box
const [origX, origY, origWidth, origHeight] = origCanvasSize;
// variables for target bounding box
let [x, y, width, height] = origCanvasSize;
const defaultAppState = getDefaultAppState(); x = cfg.x ?? x;
y = cfg.y ?? y;
width = cfg.width ?? width;
height = cfg.height ?? height;
if (cfg.fit === "contain" || cfg.widthOrHeight || cfg.maxWidthOrHeight) {
cfg.padding =
cfg.padding && cfg.padding > 0
? Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
)
: 0;
if (cfg.getDimensions != null) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.padding = Math.min(
cfg.padding,
(width - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
(height - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.widthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.widthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
} else if (cfg.maxWidthOrHeight != null) {
cfg.padding = Math.min(
cfg.padding,
(cfg.maxWidthOrHeight - DEFAULT_SMALLEST_EXPORT_SIZE) / 2,
);
}
}
if (cfg.maxWidthOrHeight != null || cfg.widthOrHeight != null) {
if (cfg.padding) {
if (cfg.maxWidthOrHeight != null) {
cfg.maxWidthOrHeight -= cfg.padding * 2;
} else if (cfg.widthOrHeight != null) {
cfg.widthOrHeight -= cfg.padding * 2;
}
}
const max = Math.max(width, height);
if (cfg.widthOrHeight != null) {
// calculate by how much do we need to scale the canvas to fit into the
// target dimension (e.g. target: max 50px, actual: 70x100px => scale: 0.5)
exportScale = cfg.widthOrHeight / max;
} else if (cfg.maxWidthOrHeight != null) {
exportScale = cfg.maxWidthOrHeight < max ? cfg.maxWidthOrHeight / max : 1;
}
width *= exportScale;
height *= exportScale;
} else if (cfg.getDimensions) {
const ret = cfg.getDimensions(width, height);
width = ret.width;
height = ret.height;
cfg.scale = ret.scale ?? cfg.scale;
} else if (cfg.fit === "contain") {
width -= cfg.padding * 2;
height -= cfg.padding * 2;
const wRatio = width / origWidth;
const hRatio = height / origHeight;
// scale the orig canvas to fit in the target region
exportScale = Math.min(wRatio, hRatio);
}
x = cfg.x ?? origX;
y = cfg.y ?? origY;
// if we switch to "content" coords, we need to offset cfg-supplied
// coords by the x/y of content bounding box
if (cfg.origin === "content") {
if (cfg.x != null) {
x += origX;
}
if (cfg.y != null) {
y += origY;
}
}
// Centering the content to the frame.
// We divide width/height by canvasScale so that we calculate in the original
// aspect ratio dimensions.
if (cfg.position === "center") {
x -=
width / exportScale / 2 -
(cfg.x == null ? origWidth : width + cfg.padding * 2) / 2;
y -=
height / exportScale / 2 -
(cfg.y == null ? origHeight : height + cfg.padding * 2) / 2;
}
// rescale padding based on current canvasScale factor so that the resulting
// padding is kept the same as supplied by user (with the exception of
// `cfg.scale` being set, which also scales the padding)
const normalizedPadding = cfg.padding / exportScale;
// scale the whole frame by cfg.scale (on top of whatever canvasScale we
// calculated above)
exportScale *= cfg.scale;
width *= cfg.scale;
height *= cfg.scale;
const exportWidth = width + cfg.padding * 2 * cfg.scale;
const exportHeight = height + cfg.padding * 2 * cfg.scale;
return {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
};
};
// ---------------------------------------------------------------------------
// exportToCanvas
// ---------------------------------------------------------------------------
/**
* This API is usually used as a precursor to searializing to Blob or PNG,
* but can also be used to create a canvas for other purposes.
*/
export const exportToCanvas = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportSceneConfig;
}) => {
const {
config: cfg,
normalizedPadding,
contentWidth: width,
contentHeight: height,
exportWidth,
exportHeight,
exportScale,
x,
y,
elementsForRender,
appState,
frameRendering,
} = await configExportDimension({ data, config });
const canvas = cfg.createCanvas
? cfg.createCanvas()
: document.createElement("canvas");
canvas.width = exportWidth;
canvas.height = exportHeight;
const { imageCache } = await updateImageCache({ const { imageCache } = await updateImageCache({
imageCache: new Map(), imageCache: new Map(),
fileIds: getInitializedImageElements(elementsForRender).map( fileIds: getInitializedImageElements(elementsForRender).map(
(element) => element.fileId, (element) => element.fileId,
), ),
files, files: data.files || {},
}); });
const theme =
cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
// Determine the background color for the canvas
const viewBackgroundColor =
cfg.canvasBackgroundColor === false
? // "transparent" triggers clearRect in bootstrapCanvas
"transparent"
: cfg.canvasBackgroundColor ||
appState.viewBackgroundColor ||
COLOR_WHITE;
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
@@ -249,19 +656,23 @@ export const exportToCanvas = async (
arrayToMap(elementsForRender), arrayToMap(elementsForRender),
), ),
allElementsMap: toBrandedType<NonDeletedSceneElementsMap>( allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(syncInvalidIndices(elements)), arrayToMap(syncInvalidIndices(data.elements)),
), ),
visibleElements: elementsForRender, visibleElements: elementsForRender,
scale, scale: exportScale,
appState: { appState: {
...appState, ...appState,
frameRendering, frameRendering,
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, width,
scrollX: -minX + exportPadding, height,
scrollY: -minY + exportPadding, offsetLeft: 0,
zoom: defaultAppState.zoom, offsetTop: 0,
scrollX: -x + normalizedPadding,
scrollY: -y + normalizedPadding,
zoom: { value: DEFAULT_ZOOM_VALUE },
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
viewBackgroundColor,
}, },
renderConfig: { renderConfig: {
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: viewBackgroundColor,
@@ -272,13 +683,44 @@ export const exportToCanvas = async (
embedsValidationStatus: new Map(), embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(), elementsPendingErasure: new Set(),
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
}); });
return canvas; return canvas;
}; };
// ---------------------------------------------------------------------------
// exportToSvg
// ---------------------------------------------------------------------------
type ExportToSvgConfig = Pick<
ExportSceneConfig,
| "canvasBackgroundColor"
| "padding"
| "theme"
| "exportingFrame"
| "scale"
| "width"
| "height"
| "x"
| "y"
| "origin"
| "fit"
| "position"
| "maxWidthOrHeight"
| "widthOrHeight"
| "getDimensions"
| "loadFonts"
> & {
/**
* if true, all embeddables passed in will be rendered when possible.
*/
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
};
const createHTMLComment = (text: string) => { const createHTMLComment = (text: string) => {
// surrounding with spaces to maintain prettified consistency with previous // surrounding with spaces to maintain prettified consistency with previous
// iterations // iterations
@@ -286,61 +728,34 @@ const createHTMLComment = (text: string) => {
return document.createComment(` ${text} `); return document.createComment(` ${text} `);
}; };
export const exportToSvg = async ( export const exportToSvg = async ({
elements: readonly NonDeletedExcalidrawElement[], data,
appState: { config,
exportBackground: boolean; }: {
exportPadding?: number; data: ExportSceneData;
exportScale?: number; config?: ExportToSvgConfig;
viewBackgroundColor: string; }) => {
exportWithDarkMode?: boolean; const {
exportEmbedScene?: boolean; config: cfg,
frameRendering?: AppState["frameRendering"]; normalizedPadding,
}, exportWidth,
files: BinaryFiles | null, exportHeight,
opts?: { exportScale,
/** x,
* if true, all embeddables passed in will be rendered when possible. y,
*/ elementsForRender,
renderEmbeddables?: boolean; appState,
exportingFrame?: ExcalidrawFrameLikeElement | null;
skipInliningFonts?: true;
reuseImages?: boolean;
},
): Promise<SVGSVGElement> => {
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
);
let {
exportPadding = DEFAULT_EXPORT_PADDING,
exportWithDarkMode = false,
viewBackgroundColor,
exportScale = 1,
exportEmbedScene,
} = appState;
const { exportingFrame = null } = opts || {};
const elementsForRender = prepareElementsForRender({
elements,
exportingFrame,
exportWithDarkMode,
frameRendering, frameRendering,
}); } = await configExportDimension({ data, config });
if (exportingFrame) { const offsetX = -(x - normalizedPadding);
exportPadding = 0; const offsetY = -(y - normalizedPadding);
}
const [minX, minY, width, height] = getCanvasSize( const { elements } = data;
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
const offsetX = -minX + exportPadding; const theme =
const offsetY = -minY + exportPadding; cfg.theme ?? (appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT);
const exportWithDarkMode = theme === THEME.DARK;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// initialize SVG root element // initialize SVG root element
@@ -350,9 +765,12 @@ export const exportToSvg = async (
svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS); svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); svgRoot.setAttribute(
svgRoot.setAttribute("width", `${width * exportScale}`); "viewBox",
svgRoot.setAttribute("height", `${height * exportScale}`); `0 0 ${exportWidth / exportScale} ${exportHeight / exportScale}`,
);
svgRoot.setAttribute("width", `${exportWidth}`);
svgRoot.setAttribute("height", `${exportHeight}`);
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs"); const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
@@ -371,7 +789,7 @@ export const exportToSvg = async (
// we need to serialize the "original" elements before we put them through // we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids // the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) { if (appState.exportEmbedScene) {
try { try {
encodeSvgBase64Payload({ encodeSvgBase64Payload({
metadataElement, metadataElement,
@@ -379,7 +797,7 @@ export const exportToSvg = async (
// elements which don't contain the temp frame labels. // elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with // But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra. // only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, files || {}, "local"), payload: serializeAsJSON(elements, appState, data.files || {}, "local"),
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -417,7 +835,7 @@ export const exportToSvg = async (
rect.setAttribute("width", `${frame.width}`); rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`); rect.setAttribute("height", `${frame.height}`);
if (!exportingFrame) { if (!cfg.exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`); rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`); rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
} }
@@ -432,9 +850,10 @@ export const exportToSvg = async (
// inline font faces // inline font faces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const fontFaces = !opts?.skipInliningFonts const fontFaces =
? await Fonts.generateFontFaceDeclarations(elements) config?.skipInliningFonts !== true
: []; ? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces const delimiter = "\n "; // 6 spaces
@@ -451,17 +870,16 @@ export const exportToSvg = async (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && appState.viewBackgroundColor) {
const bgColor = cfg.canvasBackgroundColor || appState.viewBackgroundColor;
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0"); rect.setAttribute("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${exportWidth / exportScale}`);
rect.setAttribute("height", `${height}`); rect.setAttribute("height", `${exportHeight / exportScale}`);
rect.setAttribute( rect.setAttribute(
"fill", "fill",
exportWithDarkMode exportWithDarkMode ? applyDarkModeFilter(bgColor) : bgColor,
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
); );
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
@@ -472,14 +890,14 @@ export const exportToSvg = async (
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = config?.renderEmbeddables ?? false;
renderSceneToSvg( renderSceneToSvg(
elementsForRender, elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)), toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg, rsvg,
svgRoot, svgRoot,
files || {}, data.files || {},
{ {
offsetX, offsetX,
offsetY, offsetY,
@@ -487,7 +905,7 @@ export const exportToSvg = async (
exportWithDarkMode, exportWithDarkMode,
renderEmbeddables, renderEmbeddables,
frameRendering, frameRendering,
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: appState.viewBackgroundColor,
embedsValidationStatus: renderEmbeddables embedsValidationStatus: renderEmbeddables
? new Map( ? new Map(
elementsForRender elementsForRender
@@ -495,8 +913,8 @@ export const exportToSvg = async (
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
reuseImages: opts?.reuseImages ?? true, reuseImages: config?.reuseImages ?? true,
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
); );
@@ -505,6 +923,10 @@ export const exportToSvg = async (
return svgRoot; return svgRoot;
}; };
// ---------------------------------------------------------------------------
// SVG payload encoding/decoding
// ---------------------------------------------------------------------------
export const encodeSvgBase64Payload = ({ export const encodeSvgBase64Payload = ({
payload, payload,
metadataElement, metadataElement,
@@ -560,26 +982,149 @@ export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
throw new Error("INVALID"); throw new Error("INVALID");
}; };
// ---------------------------------------------------------------------------
// getCanvasSize
// ---------------------------------------------------------------------------
// calculate smallest area to fit the contents in // calculate smallest area to fit the contents in
const getCanvasSize = ( export const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): Bounds => { ): Bounds => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = distance(minX, maxX) + exportPadding * 2; const width = distance(minX, maxX);
const height = distance(minY, maxY) + exportPadding * 2; const height = distance(minY, maxY);
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
/**
* Gets the export dimensions for a set of elements.
*
* @param elements - Elements to calculate size for
* @param exportPadding - Padding to add around the elements
* @param scale - Scale factor
* @returns [width, height] tuple
*/
export const getExportSize = ( export const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
scale: number, scale: number,
): [number, number] => { ): [number, number] => {
const [, , width, height] = getCanvasSize(elements, exportPadding).map( const [, , width, height] = getCanvasSize(elements);
(dimension) => Math.trunc(dimension * scale),
);
return [width, height]; return [
Math.trunc((width + exportPadding * 2) * scale),
Math.trunc((height + exportPadding * 2) * scale),
];
};
// ---------------------------------------------------------------------------
// exportToBlob
// ---------------------------------------------------------------------------
export { MIME_TYPES };
type ExportToBlobConfig = ExportSceneConfig & {
mimeType?: string;
quality?: number;
};
export const exportToBlob = async ({
data,
config,
}: {
data: ExportSceneData;
config?: ExportToBlobConfig;
}): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = config || {};
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && config?.canvasBackgroundColor !== false) {
if (config?.canvasBackgroundColor === undefined) {
console.warn(
`Defaulting "canvasBackgroundColor" for "${MIME_TYPES.jpg}" mimeType`,
);
config = {
...config,
canvasBackgroundColor:
data.appState?.viewBackgroundColor || COLOR_WHITE,
};
}
}
const canvas = await exportToCanvas({ data, config });
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
data.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
data.elements,
data.appState,
data.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
// ---------------------------------------------------------------------------
// exportToClipboard
// ---------------------------------------------------------------------------
export const exportToClipboard = async ({
type,
data,
config,
}: {
data: ExportSceneData;
} & (
| { type: "png"; config?: ExportToBlobConfig }
| { type: "svg"; config?: ExportToSvgConfig }
| { type: "json"; config?: never }
)) => {
if (type === "svg") {
const svg = await exportToSvg({
data: {
...data,
appState: restoreAppState(data.appState, null),
},
config,
});
await copyTextToSystemClipboard(svg.outerHTML);
} else if (type === "png") {
await copyBlobToClipboardAsPng(exportToBlob({ data, config }));
} else if (type === "json") {
await copyToClipboard(data.elements, data.files);
} else {
throw new Error("Invalid export type");
}
}; };
@@ -13,7 +13,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -1087,7 +1086,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1302,7 +1300,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1634,7 +1631,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1966,7 +1962,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2181,7 +2176,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2423,7 +2417,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2722,7 +2715,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3095,7 +3087,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3589,7 +3580,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3913,7 +3903,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4237,7 +4226,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4370,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1006504105, "versionNonce": 760410951,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4395,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 289600103, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4649,7 +4637,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -5867,7 +5854,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -6878,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 747212839,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6905,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 760410951, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -7136,7 +7122,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -7826,7 +7811,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -8818,7 +8802,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,6 @@ exports[`given element A and group of elements B and given both are selected whe
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -440,7 +439,6 @@ exports[`given element A and group of elements B and given both are selected whe
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -857,7 +855,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1424,7 +1421,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1632,7 +1628,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2017,7 +2012,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2263,7 +2257,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2444,7 +2437,6 @@ exports[`regression tests > can drag element that covers another element, while
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2770,7 +2762,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3026,7 +3017,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3268,7 +3258,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3505,7 +3494,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3764,7 +3752,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4079,7 +4066,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4516,7 +4502,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4800,7 +4785,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5077,7 +5061,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5286,7 +5269,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5487,7 +5469,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5881,7 +5862,6 @@ exports[`regression tests > drags selected elements from point inside common bou
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -6179,7 +6159,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -6968,7 +6947,6 @@ exports[`regression tests > given a group of selected elements with an element t
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7303,7 +7281,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7583,7 +7560,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7819,7 +7795,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8060,7 +8035,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8241,7 +8215,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8422,7 +8395,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8603,7 +8575,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8836,7 +8807,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9067,7 +9037,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9260,7 +9229,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9493,7 +9461,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9674,7 +9641,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9905,7 +9871,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10086,7 +10051,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10279,7 +10243,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10460,7 +10423,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10992,7 +10954,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11273,7 +11234,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11397,7 +11357,6 @@ exports[`regression tests > shift click on selected element should deselect it o
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11598,7 +11557,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11918,7 +11876,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -12348,7 +12305,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -12989,7 +12945,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -13116,7 +13071,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -13748,7 +13702,6 @@ exports[`regression tests > switches from group of selected elements to another
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14088,7 +14041,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14353,7 +14305,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14477,7 +14428,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14842,7 +14792,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14966,7 +14915,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
+14 -6
View File
@@ -83,11 +83,13 @@ describe("export", () => {
}); });
it("export svg-embedded scene", async () => { it("export svg-embedded scene", async () => {
const svg = await exportToSvg( const svg = await exportToSvg({
testElements, data: {
{ ...getDefaultAppState(), exportEmbedScene: true }, elements: testElements,
{}, appState: { ...getDefaultAppState(), exportEmbedScene: true },
); files: {},
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`); expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
@@ -205,7 +207,13 @@ describe("export", () => {
}, },
} as const; } as const;
const svg = await exportToSvg(elements, appState, files); const svg = await exportToSvg({
data: {
elements,
appState,
files,
},
});
const svgText = svg.outerHTML; const svgText = svg.outerHTML;
+2 -12
View File
@@ -4,7 +4,6 @@ import {
elementCenterPoint, elementCenterPoint,
getCommonBounds, getCommonBounds,
getElementPointsCoords, getElementPointsCoords,
getLineHeightInPx,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element";
import { import {
@@ -21,7 +20,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -517,17 +516,8 @@ export class UI {
UI.clickTool(type); UI.clickTool(type);
if (type === "text") { if (type === "text") {
const clickY = h.state.gridModeEnabled
? y
: y +
getLineHeightInPx(
h.state.currentItemFontSize,
getLineHeight(h.state.currentItemFontFamily),
) /
2;
mouse.reset(); mouse.reset();
mouse.click(x, clickY); mouse.click(x, y);
} else if ((type === "line" || type === "arrow") && points.length > 2) { } else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => { points.forEach((point) => {
mouse.reset(); mouse.reset();
+1 -77
View File
@@ -314,7 +314,7 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, isDeleted: true }), expect.objectContaining({ id: rect2.id, isDeleted: true }),
]); ]);
mouse.downAt(-10, -10); mouse.downAt(0, 0);
mouse.moveTo(25, 25); mouse.moveTo(25, 25);
mouse.moveTo(50, 50); mouse.moveTo(50, 50);
mouse.upAt(50, 50); mouse.upAt(50, 50);
@@ -2971,82 +2971,6 @@ describe("history", () => {
expect(h.state.editingGroupId).toBeNull(); 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 () => { it("should iterate through the history when selected or editing linear element was remotely deleted", async () => {
// create three point arrow // create three point arrow
UI.clickTool("arrow"); UI.clickTool("arrow");
@@ -468,7 +468,7 @@ describe("regression tests", () => {
mouse.reset(); mouse.reset();
mouse.down(); mouse.down();
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.restorePosition(...end);
mouse.up(); mouse.up();
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
@@ -519,7 +519,7 @@ describe("regression tests", () => {
mouse.reset(); mouse.reset();
mouse.down(); mouse.down();
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.restorePosition(...end);
mouse.up(); mouse.up();
for (const element of h.elements) { 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.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down(); mouse.down();
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.restorePosition(...end);
mouse.up(); mouse.up();
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
File diff suppressed because one or more lines are too long
+146 -93
View File
@@ -1,9 +1,10 @@
import { exportToCanvas, exportToSvg } from "@excalidraw/utils"; import { exportToCanvas, exportToSvg } from "../../scene/export";
import { import {
applyDarkModeFilter, applyDarkModeFilter,
FONT_FAMILY, FONT_FAMILY,
FRAME_STYLE, FRAME_STYLE,
THEME,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -53,39 +54,42 @@ describe("exportToSvg", () => {
}, },
] as NonDeletedExcalidrawElement[]; ] as NonDeletedExcalidrawElement[];
const DEFAULT_OPTIONS = { const DEFAULT_APP_STATE = {
exportBackground: false, exportBackground: false,
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
files: {},
}; };
it("with default arguments", async () => { it("with default arguments", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
DEFAULT_OPTIONS, elements: ELEMENTS,
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
}); });
it("with a CJK font", async () => { it("with a CJK font", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
[ data: {
...ELEMENTS, elements: [
{ ...ELEMENTS,
...textFixture, {
height: ELEMENT_HEIGHT, ...textFixture,
width: ELEMENT_WIDTH, height: ELEMENT_HEIGHT,
text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", width: ELEMENT_WIDTH,
originalText: text: "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
"中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.", originalText:
index: "a4" as FractionalIndex, "中国你好!这是一个测试。中国你好!日本こんにちは!これはテストです。한국 안녕하세요! 이것은 테스트입니다.",
} as ExcalidrawTextElement, index: "a4" as FractionalIndex,
], } as ExcalidrawTextElement,
DEFAULT_OPTIONS, ],
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement).toMatchSnapshot(); expect(svgElement).toMatchSnapshot();
// extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers) // extend the timeout, as it needs to first load the fonts from disk and then perform whole woff2 decode, subset and encode (without workers)
@@ -94,15 +98,17 @@ describe("exportToSvg", () => {
it("with background color", async () => { it("with background color", async () => {
const BACKGROUND_COLOR = "#abcdef"; const BACKGROUND_COLOR = "#abcdef";
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportBackground: true, ...DEFAULT_APP_STATE,
viewBackgroundColor: BACKGROUND_COLOR, exportBackground: true,
viewBackgroundColor: BACKGROUND_COLOR,
},
files: {},
}, },
null, });
);
expect(svgElement.querySelector("rect")).toHaveAttribute( expect(svgElement.querySelector("rect")).toHaveAttribute(
"fill", "fill",
@@ -111,14 +117,18 @@ describe("exportToSvg", () => {
}); });
it("with dark mode", async () => { it("with dark mode", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportWithDarkMode: true, ...DEFAULT_APP_STATE,
},
files: {},
}, },
null, config: {
); theme: THEME.DARK,
},
});
const textElements = svgElement.querySelectorAll("text"); const textElements = svgElement.querySelectorAll("text");
expect(textElements.length).toBeGreaterThan(0); expect(textElements.length).toBeGreaterThan(0);
@@ -132,14 +142,16 @@ describe("exportToSvg", () => {
}); });
it("with exportPadding", async () => { it("with exportPadding", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: DEFAULT_APP_STATE,
exportPadding: 0, files: {},
}, },
null, config: {
); padding: 0,
},
});
expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString()); expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString()); expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
@@ -152,15 +164,17 @@ describe("exportToSvg", () => {
it("with scale", async () => { it("with scale", async () => {
const SCALE = 2; const SCALE = 2;
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: DEFAULT_APP_STATE,
exportPadding: 0, files: {},
exportScale: SCALE,
}, },
null, config: {
); padding: 0,
scale: SCALE,
},
});
expect(svgElement).toHaveAttribute( expect(svgElement).toHaveAttribute(
"height", "height",
@@ -173,23 +187,27 @@ describe("exportToSvg", () => {
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
ELEMENTS, data: {
{ elements: ELEMENTS,
...DEFAULT_OPTIONS, appState: {
exportEmbedScene: true, ...DEFAULT_APP_STATE,
exportEmbedScene: true,
},
files: {},
}, },
null, });
);
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
it("with elements that have a link", async () => { it("with elements that have a link", async () => {
const svgElement = await exportUtils.exportToSvg( const svgElement = await exportUtils.exportToSvg({
[rectangleWithLinkFixture], data: {
DEFAULT_OPTIONS, elements: [rectangleWithLinkFixture],
null, appState: DEFAULT_APP_STATE,
); files: {},
},
});
expect(svgElement.innerHTML).toMatchSnapshot(); expect(svgElement.innerHTML).toMatchSnapshot();
}); });
}); });
@@ -229,9 +247,14 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements, data: {
files: null, elements,
exportPadding: 0, appState: {},
files: null,
},
config: {
padding: 0,
},
}); });
expect(canvas.width).toEqual(200); expect(canvas.width).toEqual(200);
@@ -258,10 +281,15 @@ describe("exporting frames", () => {
]; ];
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements, data: {
files: null, elements,
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
expect(canvas.width).toEqual(frame.width); expect(canvas.width).toEqual(frame.width);
@@ -297,10 +325,15 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [rectOverlapping, frame, frameChild], data: {
files: null, elements: [rectOverlapping, frame, frameChild],
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -341,10 +374,15 @@ describe("exporting frames", () => {
}); });
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: [frameChild, frame, elementOutside], data: {
files: null, elements: [frameChild, frame, elementOutside],
exportPadding: 0, appState: {},
exportingFrame: frame, files: null,
},
config: {
padding: 0,
exportingFrame: frame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -409,10 +447,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frames themselves should be exported when multiple frames selected // frames themselves should be exported when multiple frames selected
@@ -454,10 +497,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame itself isn't exported // frame itself isn't exported
@@ -513,10 +561,15 @@ describe("exporting frames", () => {
); );
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: exportedElements, data: {
files: null, elements: exportedElements,
exportPadding: 0, appState: {},
exportingFrame, files: null,
},
config: {
padding: 0,
exportingFrame,
},
}); });
// frame shouldn't be exported // frame shouldn't be exported
+6 -568
View File
@@ -1,9 +1,7 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; import { KEYS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes"; import { SHAPES } from "../components/shapes";
@@ -14,7 +12,6 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
act,
render, render,
fireEvent, fireEvent,
mockBoundingClientRect, mockBoundingClientRect,
@@ -42,19 +39,6 @@ const { h } = window;
const mouse = new Pointer("mouse"); 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", () => { describe("box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -124,497 +108,6 @@ describe("box-selection", () => {
assertSelectedElements([]); 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", () => { describe("inner box-selection", () => {
@@ -833,7 +326,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -866,7 +359,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -899,7 +392,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -945,7 +438,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -990,7 +483,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -1065,58 +558,3 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_2); 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({});
});
});
+20 -19
View File
@@ -449,28 +449,29 @@ const stripProps = (
}, {} as Record<string, any>); }, {} as Record<string, any>);
export const checkpointHistory = (history: History, name: string) => { export const checkpointHistory = (history: History, name: string) => {
const normalizeHistoryEntry = (entry: History["undoStack"][number]) => { expect(
const { markers, ...rest } = entry; history.undoStack.map((x) => ({
...x,
return {
...rest,
...(markers ? { markers } : {}),
elements: { elements: {
...entry.elements, ...x.elements,
added: stripProps(entry.elements.added, ["seed", "versionNonce"]), added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripProps(entry.elements.removed, ["seed", "versionNonce"]), removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(entry.elements.updated, ["seed", "versionNonce"]), updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
}, },
}; })),
}; ).toMatchSnapshot(`[${name}] undo stack`);
expect(history.undoStack.map(normalizeHistoryEntry)).toMatchSnapshot( expect(
`[${name}] undo stack`, history.redoStack.map((x) => ({
); ...x,
elements: {
expect(history.redoStack.map(normalizeHistoryEntry)).toMatchSnapshot( ...x.elements,
`[${name}] redo stack`, added: stripProps(x.elements.added, ["seed", "versionNonce"]),
); removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
},
})),
).toMatchSnapshot(`[${name}] redo stack`);
}; };
/** /**
File diff suppressed because it is too large Load Diff
@@ -1,88 +0,0 @@
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
);
};
-289
View File
@@ -1,289 +0,0 @@
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>());
};
-13
View File
@@ -1,13 +0,0 @@
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";
-333
View File
@@ -1,333 +0,0 @@
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;
}
}
-291
View File
@@ -1,291 +0,0 @@
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);
}
}
@@ -1,286 +0,0 @@
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;
}
}
-83
View File
@@ -1,83 +0,0 @@
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;
@@ -1,283 +0,0 @@
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);
}
}
+4 -11
View File
@@ -32,7 +32,6 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode, BindMode,
ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -269,8 +268,6 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
}; };
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState { export interface AppState {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
@@ -309,8 +306,6 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt) * `bindingPreference` and keyboard modifiers (ctrl/alt)
*/ */
isBindingEnabled: boolean; isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode: BoxSelectionMode;
/** user arrow binding preference */ /** user arrow binding preference */
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */
@@ -332,7 +327,7 @@ export interface AppState {
/** /**
* set when a new text is created or when an existing text is being edited * set when a new text is created or when an existing text is being edited
*/ */
editingTextElement: ExcalidrawTextElement | null; editingTextElement: NonDeletedExcalidrawElement | null;
activeTool: { activeTool: {
/** /**
* indicates a previous tool we should revert back to if we deselect the * indicates a previous tool we should revert back to if we deselect the
@@ -796,9 +791,6 @@ export type AppClassProperties = {
files: BinaryFiles; files: BinaryFiles;
editorInterface: App["editorInterface"]; editorInterface: App["editorInterface"];
scene: App["scene"]; scene: App["scene"];
store: App["store"];
transactionManager: App["transactionManager"];
createTransaction: App["createTransaction"];
syncActionResult: App["syncActionResult"]; syncActionResult: App["syncActionResult"];
fonts: App["fonts"]; fonts: App["fonts"];
pasteFromClipboard: App["pasteFromClipboard"]; pasteFromClipboard: App["pasteFromClipboard"];
@@ -884,8 +876,9 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset // by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication. // to current pointer position at time of duplication.
origin: { x: number; y: number }; origin: { x: number; y: number };
// explicit flag for specific scenarios such as: // Whether to block drag after lasso selection
// - after lasso selection until the next pointer down // this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean; blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
@@ -1,10 +1,7 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { import { getOriginalContainerHeightFromCache } from "@excalidraw/element";
getLineHeightInPx,
getOriginalContainerHeightFromCache,
} from "@excalidraw/element";
import { import {
CODES, CODES,
@@ -45,28 +42,6 @@ unmountComponent();
const tab = " "; const tab = " ";
const mouse = new Pointer("mouse"); 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("textWysiwyg", () => {
describe("start text editing", () => { describe("start text editing", () => {
const { h } = window; const { h } = window;
@@ -235,42 +210,6 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); 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 () => { it("should edit text under cursor when double-clicked with selection tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
@@ -293,94 +232,6 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); 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. // FIXME too flaky. No one knows why.
it.skip("should bump the version of a labeled arrow when the label is updated", async () => { it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
@@ -1354,40 +1205,6 @@ 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 () => { it("should restore original container height and clear cache once text is unbind", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
@@ -1694,7 +1511,7 @@ describe("textWysiwyg", () => {
version: 2, version: 2,
width: 610, width: 610,
x: 15, x: 15,
y: 12.5, y: 25,
}), }),
); );
expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+2 -203
View File
@@ -10,9 +10,7 @@ import {
isTestEnv, isTestEnv,
MIME_TYPES, MIME_TYPES,
applyDarkModeFilter, applyDarkModeFilter,
isRTL,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import { import {
getTextFromElements, getTextFromElements,
@@ -35,11 +33,8 @@ import {
getBoundTextElement, getBoundTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getTextWidth } from "@excalidraw/element"; import { getTextWidth } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import { getLineWidth } from "@excalidraw/element";
import { normalizeText } from "@excalidraw/element"; import { normalizeText } from "@excalidraw/element";
import { wrapText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element";
import { getWrappedTextLines } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
@@ -96,103 +91,6 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; 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; type SubmitHandler = () => void;
export const textWysiwyg = ({ export const textWysiwyg = ({
@@ -205,7 +103,6 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
initialCaretSceneCoords = null,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@@ -222,19 +119,7 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null; excalidrawContainer: HTMLDivElement | null;
app: App; app: App;
autoSelect?: boolean; autoSelect?: boolean;
initialCaretSceneCoords?: { x: number; y: number } | null;
}): SubmitHandler => { }): 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 = ( const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
@@ -369,7 +254,6 @@ export const textWysiwyg = ({
height *= 1.05; height *= 1.05;
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
const angle = getTextElementAngle(updatedTextElement, container);
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
@@ -385,7 +269,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
width, width,
height, height,
angle, getTextElementAngle(updatedTextElement, container),
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -399,19 +283,6 @@ export const textWysiwyg = ({
opacity: updatedTextElement.opacity / 100, opacity: updatedTextElement.opacity / 100,
maxHeight: `${editorMaxHeight}px`, 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; editable.scrollTop = 0;
// For some reason updating font attribute doesn't set font family // For some reason updating font attribute doesn't set font family
// hence updating font family explicitly for test environment // hence updating font family explicitly for test environment
@@ -462,71 +333,6 @@ export const textWysiwyg = ({
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); 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) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
// we need to synchronously get the MIME types so we can preventDefault() // we need to synchronously get the MIME types so we can preventDefault()
@@ -890,13 +696,6 @@ export const textWysiwyg = ({
// Otherwise, re-enable submit on blur and refocus the editor. // Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
editable.focus(); editable.focus();
if (pendingInitialSelection) {
editable.setSelectionRange(
pendingInitialSelection.start,
pendingInitialSelection.end,
);
pendingInitialSelection = null;
}
}); });
}; };
@@ -987,7 +786,7 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
if (autoSelect && !pendingInitialSelection) { if (autoSelect) {
// select on init (focusing is done separately inside the bindBlurEvent() // select on init (focusing is done separately inside the bindBlurEvent()
// because we need it to happen *after* the blur event from `pointerdown`) // because we need it to happen *after* the blur event from `pointerdown`)
editable.select(); editable.select();
+5 -39
View File
@@ -137,17 +137,8 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
[t0, s0]: [number, number], [t0, s0]: [number, number],
l: LineSegment<Point>, l: LineSegment<Point>,
c: Curve<Point>, c: Curve<Point>,
tolerance: number = 1e-2,
iterLimit: number = 4,
) => { ) => {
const solution = solveWithAnalyticalJacobian( const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4);
c,
l,
t0,
s0,
tolerance,
iterLimit,
);
if (!solution) { if (!solution) {
return null; return null;
@@ -167,43 +158,18 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
*/ */
export function curveIntersectLineSegment< export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint, Point extends GlobalPoint | LocalPoint,
>( >(c: Curve<Point>, l: LineSegment<Point>): Point[] {
c: Curve<Point>, let solution = calculate(initial_guesses[0], l, c);
l: LineSegment<Point>,
opts?: {
tolerance?: number;
iterLimit?: number;
},
): Point[] {
let solution = calculate(
initial_guesses[0],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate( solution = calculate(initial_guesses[1], l, c);
initial_guesses[1],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate( solution = calculate(initial_guesses[2], l, c);
initial_guesses[2],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
+2 -7
View File
@@ -123,15 +123,10 @@ export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
* @returns The rotated point * @returns The rotated point
*/ */
export function pointRotateRads<Point extends GlobalPoint | LocalPoint>( export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
point: Point, [x, y]: Point,
center: Point, [cx, cy]: Point,
angle: Radians, angle: Radians,
): Point { ): Point {
if (!angle) {
return point;
}
const [x, y] = point;
const [cx, cy] = center;
return pointFrom( return pointFrom(
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
+1 -1
View File
@@ -40,7 +40,7 @@ export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
Array.isArray(segment) && Array.isArray(segment) &&
segment.length === 2 && segment.length === 2 &&
isPoint(segment[0]) && isPoint(segment[0]) &&
isPoint(segment[1]); isPoint(segment[0]);
/** /**
* Return the coordinates resulting from rotating the given line about an origin by an angle in radians * Return the coordinates resulting from rotating the given line about an origin by an angle in radians
+1 -25
View File
@@ -1,9 +1,5 @@
import { pointFrom } from "../src/point"; import { pointFrom } from "../src/point";
import { import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment";
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => { describe("line-segment intersections", () => {
it("should correctly detect intersection", () => { it("should correctly detect intersection", () => {
@@ -23,23 +19,3 @@ describe("line-segment intersections", () => {
).toEqual(null); ).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);
});
});
-216
View File
@@ -1,216 +0,0 @@
import { MIME_TYPES } from "@excalidraw/common";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
copyToClipboard,
} from "@excalidraw/excalidraw/clipboard";
import { encodePngMetadata } from "@excalidraw/excalidraw/data/image";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import {
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import {
exportToCanvas as _exportToCanvas,
exportToSvg as _exportToSvg,
} from "@excalidraw/excalidraw/scene/export";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
} from "@excalidraw/element/types";
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
export { MIME_TYPES };
type ExportOpts = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: (
width: number,
height: number,
) => { width: number; height: number; scale?: number };
};
export const exportToCanvas = ({
elements,
appState,
files,
maxWidthOrHeight,
getDimensions,
exportPadding,
exportingFrame,
}: ExportOpts & {
exportPadding?: number;
}) => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
restoredElements,
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {},
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
(width: number, height: number) => {
const canvas = document.createElement("canvas");
if (maxWidthOrHeight) {
if (typeof getDimensions === "function") {
console.warn(
"`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.",
);
}
const max = Math.max(width, height);
// if content is less then maxWidthOrHeight, fallback on supplied scale
const scale =
maxWidthOrHeight < max
? maxWidthOrHeight / max
: appState?.exportScale ?? 1;
canvas.width = width * scale;
canvas.height = height * scale;
return {
canvas,
scale,
};
}
const ret = getDimensions?.(width, height) || { width, height };
canvas.width = ret.width;
canvas.height = ret.height;
return {
canvas,
scale: ret.scale ?? 1,
};
},
);
};
export const exportToBlob = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
exportPadding?: number;
},
): Promise<Blob> => {
let { mimeType = MIME_TYPES.png, quality } = opts;
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
}
// typo in MIME type (should be "jpeg")
if (mimeType === "image/jpg") {
mimeType = MIME_TYPES.jpg;
}
if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) {
console.warn(
`Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
);
opts = {
...opts,
appState: { ...opts.appState, exportBackground: true },
};
}
const canvas = await exportToCanvas(opts);
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error("couldn't export to blob"));
}
if (
blob &&
mimeType === MIME_TYPES.png &&
opts.appState?.exportEmbedScene
) {
blob = await encodePngMetadata({
blob,
metadata: serializeAsJSON(
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
opts.elements,
opts.appState,
opts.files || {},
"local",
),
});
}
resolve(blob);
},
mimeType,
quality,
);
});
};
export const exportToSvg = async ({
elements,
appState = getDefaultAppState(),
files = {},
exportPadding,
renderEmbeddables,
exportingFrame,
skipInliningFonts,
reuseImages,
}: Omit<ExportOpts, "getDimensions"> & {
exportPadding?: number;
renderEmbeddables?: boolean;
skipInliningFonts?: true;
reuseImages?: boolean;
}): Promise<SVGSVGElement> => {
const restoredElements = restoreElements(elements, null, {
deleteInvisibleElements: true,
});
const restoredAppState = restoreAppState(appState, null);
const exportAppState = {
...restoredAppState,
exportPadding,
};
return _exportToSvg(restoredElements, exportAppState, files, {
exportingFrame,
renderEmbeddables,
skipInliningFonts,
reuseImages,
});
};
export const exportToClipboard = async (
opts: ExportOpts & {
mimeType?: string;
quality?: number;
type: "png" | "svg" | "json";
},
) => {
if (opts.type === "svg") {
const svg = await exportToSvg(opts);
await copyTextToSystemClipboard(svg.outerHTML);
} else if (opts.type === "png") {
await copyBlobToClipboardAsPng(exportToBlob(opts));
} else if (opts.type === "json") {
await copyToClipboard(opts.elements, opts.files);
} else {
throw new Error("Invalid export type");
}
};
+14 -1
View File
@@ -1,4 +1,17 @@
export * from "./export"; // Re-export from @excalidraw/excalidraw for backwards compatibility
export {
exportToCanvas,
exportToBlob,
exportToSvg,
exportToClipboard,
MIME_TYPES,
} from "@excalidraw/excalidraw/scene/export";
export type {
ExportSceneData,
ExportSceneConfig,
} from "@excalidraw/excalidraw/scene/export";
export * from "./withinBounds"; export * from "./withinBounds";
export * from "./bbox"; export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element"; export { getCommonBounds } from "@excalidraw/element";
@@ -13,7 +13,6 @@ exports[`exportToSvg > with default arguments 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
+58 -35
View File
@@ -10,9 +10,22 @@ const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
describe("exportToCanvas", async () => { describe("exportToCanvas", async () => {
const EXPORT_PADDING = 10; const EXPORT_PADDING = 10;
it("with default arguments", async () => { it("with default arguments (no padding)", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
});
// New API has no default padding - call sites must explicitly set it
expect(canvas.width).toBe(100);
expect(canvas.height).toBe(100);
});
it("with padding", async () => {
const canvas = await utils.exportToCanvas({
data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
config: {
padding: EXPORT_PADDING,
},
}); });
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING); expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
@@ -21,8 +34,10 @@ describe("exportToCanvas", async () => {
it("when custom width and height", async () => { it("when custom width and height", async () => {
const canvas = await utils.exportToCanvas({ const canvas = await utils.exportToCanvas({
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }), data: diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
getDimensions: () => ({ width: 200, height: 200, scale: 1 }), config: {
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
},
}); });
expect(canvas.width).toBe(200); expect(canvas.width).toBe(200);
@@ -33,20 +48,27 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => { describe("exportToBlob", async () => {
describe("mime type", () => { describe("mime type", () => {
it("should change image/jpg to image/jpeg", async () => { it("should change image/jpg to image/jpeg", async () => {
const diagramData = diagramFactory();
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), data: {
getDimensions: (width, height) => ({ width, height, scale: 1 }), elements: diagramData.elements,
// testing typo in MIME type (jpg → jpeg) appState: {
mimeType: "image/jpg", ...diagramData.appState,
appState: { exportBackground: true,
exportBackground: true, },
files: diagramData.files,
},
config: {
getDimensions: (width, height) => ({ width, height, scale: 1 }),
// testing typo in MIME type (jpg → jpeg)
mimeType: "image/jpg",
}, },
}); });
expect(blob?.type).toBe(MIME_TYPES.jpg); expect(blob?.type).toBe(MIME_TYPES.jpg);
}); });
it("should default to image/png", async () => { it("should default to image/png", async () => {
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), data: diagramFactory(),
}); });
expect(blob?.type).toBe(MIME_TYPES.png); expect(blob?.type).toBe(MIME_TYPES.png);
}); });
@@ -56,9 +78,11 @@ describe("exportToBlob", async () => {
.spyOn(console, "warn") .spyOn(console, "warn")
.mockImplementationOnce(() => void 0); .mockImplementationOnce(() => void 0);
await utils.exportToBlob({ await utils.exportToBlob({
...diagramFactory(), data: diagramFactory(),
mimeType: MIME_TYPES.png, config: {
quality: 1, mimeType: MIME_TYPES.png,
quality: 1,
},
}); });
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`, `"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
@@ -68,8 +92,9 @@ describe("exportToBlob", async () => {
}); });
describe("exportToSvg", () => { describe("exportToSvg", () => {
const passedElements = () => exportToSvgSpy.mock.calls[0][0]; const getPassedArg = () => exportToSvgSpy.mock.calls[0][0];
const passedOptions = () => exportToSvgSpy.mock.calls[0][1]; const passedData = () => getPassedArg().data;
const passedConfig = () => getPassedArg().config;
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -77,18 +102,14 @@ describe("exportToSvg", () => {
it("with default arguments", async () => { it("with default arguments", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
}), }),
}); });
const passedOptionsWhenDefault = { const data = passedData();
...passedOptions(), expect(data.elements.length).toBe(3);
// To avoid varying snapshots expect(passedConfig()).toMatchSnapshot();
name: "name",
};
expect(passedElements().length).toBe(3);
expect(passedOptionsWhenDefault).toMatchSnapshot();
}); });
// FIXME the utils.exportToSvg no longer filters out deleted elements. // FIXME the utils.exportToSvg no longer filters out deleted elements.
@@ -96,37 +117,39 @@ describe("exportToSvg", () => {
// type-checking for it correctly. // type-checking for it correctly.
it.skip("with deleted elements", async () => { it.skip("with deleted elements", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { appState: void 0 }, overrides: { appState: void 0 },
elementOverrides: { isDeleted: true }, elementOverrides: { isDeleted: true },
}), }),
}); });
expect(passedElements().length).toBe(0); expect(passedData().elements.length).toBe(0);
}); });
it("with exportPadding", async () => { it("with padding", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ overrides: { appState: { name: "diagram name" } } }), data: diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
exportPadding: 0, config: {
padding: 0,
},
}); });
expect(passedElements().length).toBe(3); expect(passedData().elements.length).toBe(3);
expect(passedOptions()).toEqual( expect(passedConfig()).toEqual(
expect.objectContaining({ exportPadding: 0 }), expect.objectContaining({ padding: 0 }),
); );
}); });
it("with exportEmbedScene", async () => { it("with exportEmbedScene", async () => {
await utils.exportToSvg({ await utils.exportToSvg({
...diagramFactory({ data: diagramFactory({
overrides: { overrides: {
appState: { name: "diagram name", exportEmbedScene: true }, appState: { name: "diagram name", exportEmbedScene: true },
}, },
}), }),
}); });
expect(passedElements().length).toBe(3); expect(passedData().elements.length).toBe(3);
expect(passedOptions().exportEmbedScene).toBe(true); expect(passedData().appState?.exportEmbedScene).toBe(true);
}); });
}); });
+19 -13
View File
@@ -19,13 +19,15 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({ const svgNode = await utils.exportToSvg({
elements: sourceElements, data: {
appState: { elements: sourceElements,
viewBackgroundColor: "#ffffff", appState: {
gridModeEnabled: false, viewBackgroundColor: "#ffffff",
exportEmbedScene: true, gridModeEnabled: false,
exportEmbedScene: true,
},
files: null,
}, },
files: null,
}); });
const svg = svgNode.outerHTML; const svg = svgNode.outerHTML;
@@ -49,14 +51,18 @@ describe("embedding scene data", () => {
const sourceElements = [rectangle, ellipse]; const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
mimeType: "image/png", data: {
elements: sourceElements, elements: sourceElements,
appState: { appState: {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
gridModeEnabled: false, gridModeEnabled: false,
exportEmbedScene: true, exportEmbedScene: true,
},
files: null,
},
config: {
mimeType: "image/png",
}, },
files: null,
}); });
const parsedString = await decodePngMetadata(blob); const parsedString = await decodePngMetadata(blob);
-7
View File
@@ -90,13 +90,6 @@ module.exports.woff2BrowserPlugin = () => {
type="font/woff2" type="font/woff2"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link
rel="preload"
href="${OSS_FONTS_CDN}fonts/Assistant/Assistant-SemiBold.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link <link
rel="preload" rel="preload"
href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2" href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2"
+4 -4
View File
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.2.2": "@excalidraw/mermaid-to-excalidraw@2.1.1":
version "2.2.2" version "2.1.1"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959"
integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg== integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3" "@mermaid-js/parser" "^0.6.3"