Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d3f116e65 | |||
| c08be69618 | |||
| b42b1a193d | |||
| f6d85bc80f | |||
| 0457ac9063 | |||
| 13d6f6cf1d | |||
| 73c940bcf6 | |||
| 60f29dc188 | |||
| 4a161c1764 | |||
| 4e94b02375 | |||
| d8e8d1aeda | |||
| d142190796 | |||
| 0598f945f6 | |||
| 47c2fa9a39 | |||
| b0339916ab | |||
| 5b7bcbec9c | |||
| 3a2d147dbd | |||
| 37603d8e0b | |||
| 05e5b13466 | |||
| 281c99e2d1 | |||
| b2b2815954 | |||
| d992c10bc1 | |||
| 091b9053a3 | |||
| 97274a74b2 | |||
| c59fb8dcbc | |||
| 7f56cc0cf3 | |||
| 974b338b7e | |||
| d2557474e2 | |||
| 3004c642da | |||
| 2dfcc6f0ce | |||
| 3f5fdec04e | |||
| 278cd35772 | |||
| 43fa4b5602 |
@@ -9,11 +9,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Set up publish access
|
||||
|
||||
@@ -9,5 +9,5 @@ jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- run: docker build -t excalidraw .
|
||||
|
||||
@@ -7,10 +7,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
with:
|
||||
|
||||
@@ -6,11 +6,97 @@ on:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
jobs:
|
||||
semantic:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
|
||||
with:
|
||||
requireScope: true
|
||||
scopes: |
|
||||
app
|
||||
editor
|
||||
packages/excalidraw
|
||||
packages/utils
|
||||
docker
|
||||
repo
|
||||
ignoreLabels: |
|
||||
skip-semantic-title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
label-scope:
|
||||
needs: semantic
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label scoped PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
scope_labels=(s-app s-editor s-package)
|
||||
|
||||
readarray -t desired_labels < <(
|
||||
node <<'NODE'
|
||||
const title = process.env.PR_TITLE;
|
||||
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
|
||||
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
|
||||
const labels = new Set();
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (scope === "app") {
|
||||
labels.add("s-app");
|
||||
} else if (scope === "editor") {
|
||||
labels.add("s-editor");
|
||||
} else if (scope.startsWith("packages/")) {
|
||||
labels.add("s-package");
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write([...labels].join("\n"));
|
||||
NODE
|
||||
)
|
||||
|
||||
should_apply_label() {
|
||||
local label="$1"
|
||||
|
||||
for desired_label in "${desired_labels[@]}"; do
|
||||
if [[ "$desired_label" == "$label" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
for label in "${scope_labels[@]}"; do
|
||||
if ! should_apply_label "$label"; then
|
||||
gh api \
|
||||
--method DELETE \
|
||||
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
|
||||
--silent 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
for label in "${desired_labels[@]}"; do
|
||||
if ! gh api \
|
||||
--method POST \
|
||||
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
|
||||
--field "labels[]=${label}" \
|
||||
--silent; then
|
||||
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
sentry:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install and build
|
||||
|
||||
@@ -10,9 +10,9 @@ jobs:
|
||||
CI_JOB_NUMBER: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install in packages/excalidraw
|
||||
|
||||
@@ -10,9 +10,9 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
- name: "Install Deps"
|
||||
|
||||
@@ -8,9 +8,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install and test
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM --platform=${BUILDPLATFORM} node:24 AS build
|
||||
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -7,13 +7,13 @@ COPY . .
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
|
||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||
@@ -39,6 +38,7 @@ import type {
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { appJotaiStore, atom } from "../app-jotai";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import { FileManager } from "./FileManager";
|
||||
|
||||
@@ -75,6 +75,13 @@ export default defineConfig(({ mode }) => {
|
||||
find: /^@excalidraw\/utils\/(.*?)/,
|
||||
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/fractional-indexing$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../packages/fractional-indexing/src/index.ts",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
build: {
|
||||
|
||||
+2
-1
@@ -56,7 +56,8 @@
|
||||
"build:element": "yarn --cwd ./packages/element build:esm",
|
||||
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:math": "yarn --cwd ./packages/math build:esm",
|
||||
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
|
||||
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
|
||||
@@ -337,9 +337,10 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
|
||||
maxWidthOrHeight: 1440,
|
||||
maxFileSizeBytes: 4 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0"
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/fractional-indexing": "3.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,29 +338,20 @@ export class Scene {
|
||||
this.callbacks.clear();
|
||||
}
|
||||
|
||||
insertElementAtIndex(element: ExcalidrawElement, index: number) {
|
||||
if (!Number.isFinite(index) || index < 0) {
|
||||
throw new Error(
|
||||
"insertElementAtIndex can only be called with index >= 0",
|
||||
);
|
||||
}
|
||||
|
||||
const nextElements = [
|
||||
...this.elements.slice(0, index),
|
||||
element,
|
||||
...this.elements.slice(index),
|
||||
];
|
||||
|
||||
syncMovedIndices(nextElements, arrayToMap([element]));
|
||||
|
||||
this.replaceAllElements(nextElements);
|
||||
}
|
||||
|
||||
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
||||
/** low-level - generally use app.insertNewElements() */
|
||||
insertElementsAtIndex(
|
||||
elements: ExcalidrawElement[],
|
||||
/** null indicates end of the array */
|
||||
index: number | null,
|
||||
) {
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index === null) {
|
||||
index = this.elements.length;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(index) || index < 0) {
|
||||
throw new Error(
|
||||
"insertElementAtIndex can only be called with index >= 0",
|
||||
@@ -378,24 +369,9 @@ export class Scene {
|
||||
this.replaceAllElements(nextElements);
|
||||
}
|
||||
|
||||
/** low-level - generally use app.insertNewElement() */
|
||||
insertElement = (element: ExcalidrawElement) => {
|
||||
const index = element.frameId
|
||||
? this.getElementIndex(element.frameId)
|
||||
: this.elements.length;
|
||||
|
||||
this.insertElementAtIndex(element, index);
|
||||
};
|
||||
|
||||
insertElements = (elements: ExcalidrawElement[]) => {
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = elements[0]?.frameId
|
||||
? this.getElementIndex(elements[0].frameId)
|
||||
: this.elements.length;
|
||||
|
||||
this.insertElementsAtIndex(elements, index);
|
||||
this.insertElementsAtIndex([element], null);
|
||||
};
|
||||
|
||||
getElementIndex(elementId: string) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getBindingGap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
unbindBindingElement,
|
||||
updateBoundPoint,
|
||||
} from "../binding";
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
isElbowArrow,
|
||||
} from "../typeChecks";
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
|
||||
import { getHoveredElementForBinding, hitElementItself } from "../collision";
|
||||
import { moveArrowAboveBindable } from "../zindex";
|
||||
|
||||
import type {
|
||||
@@ -92,7 +91,7 @@ export const isFocusPointVisible = (
|
||||
element: bindableElement,
|
||||
elementsMap,
|
||||
point: focusPoint,
|
||||
threshold: getBindingGap(bindableElement, arrow),
|
||||
threshold: getBindingGap(bindableElement),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
);
|
||||
@@ -234,12 +233,12 @@ export const handleFocusPointDrag = (
|
||||
pointerCoords.y - offsetY,
|
||||
);
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const hit = getHoveredElementForFocusPoint(
|
||||
point,
|
||||
const hit = getHoveredElementForBinding(
|
||||
arrow,
|
||||
point,
|
||||
scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
// Hovering a bindable element
|
||||
@@ -270,6 +269,7 @@ export const handleFocusPointDrag = (
|
||||
newMode || "orbit",
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
appState.zoom,
|
||||
point,
|
||||
);
|
||||
}
|
||||
|
||||
+43
-140
@@ -60,6 +60,7 @@ import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
getSnapOutlineMidPoint,
|
||||
projectFixedPointOntoDiagonal,
|
||||
} from "./utils";
|
||||
|
||||
@@ -110,18 +111,13 @@ export type BindingStrategy =
|
||||
* IMPORTANT: currently must be > 0 (this also applies to the computed gap)
|
||||
*/
|
||||
export const BASE_BINDING_GAP = 5;
|
||||
export const BASE_BINDING_GAP_ELBOW = 5;
|
||||
export const BASE_ARROW_MIN_LENGTH = 10;
|
||||
export const FOCUS_POINT_SIZE = 10 / 1.5;
|
||||
|
||||
export const getBindingGap = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
opts: Pick<ExcalidrawArrowElement, "elbowed">,
|
||||
): number => {
|
||||
return (
|
||||
(opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) +
|
||||
bindTarget.strokeWidth / 2
|
||||
);
|
||||
return BASE_BINDING_GAP + bindTarget.strokeWidth / 2;
|
||||
};
|
||||
|
||||
export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
|
||||
@@ -175,6 +171,7 @@ export const bindOrUnbindBindingElement = (
|
||||
start,
|
||||
"start",
|
||||
scene,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
);
|
||||
bindOrUnbindBindingElementEdge(
|
||||
@@ -182,6 +179,7 @@ export const bindOrUnbindBindingElement = (
|
||||
end,
|
||||
"end",
|
||||
scene,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
);
|
||||
if (start.focusPoint || end.focusPoint) {
|
||||
@@ -226,6 +224,7 @@ const bindOrUnbindBindingElementEdge = (
|
||||
{ mode, element, focusPoint }: BindingStrategy,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
shouldSnapToOutline = true,
|
||||
): void => {
|
||||
if (mode === null) {
|
||||
@@ -238,6 +237,7 @@ const bindOrUnbindBindingElementEdge = (
|
||||
mode,
|
||||
startOrEnd,
|
||||
scene,
|
||||
zoom,
|
||||
focusPoint,
|
||||
shouldSnapToOutline,
|
||||
);
|
||||
@@ -270,10 +270,11 @@ const bindingStrategyForElbowArrowEndpointDragging = (
|
||||
elementsMap,
|
||||
);
|
||||
const hit = getHoveredElementForBinding(
|
||||
arrow,
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(zoom),
|
||||
zoom,
|
||||
);
|
||||
|
||||
const current = hit
|
||||
@@ -321,7 +322,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
|
||||
draggingPoints.get(startDragged ? startIdx : endIdx)!.point,
|
||||
elementsMap,
|
||||
);
|
||||
const hit = getHoveredElementForBinding(point, elements, elementsMap);
|
||||
const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap);
|
||||
|
||||
// With new arrows this handles the binding at arrow creation
|
||||
if (startDragged) {
|
||||
@@ -366,7 +367,12 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
|
||||
// Check and handle nested shapes
|
||||
if (hit && arrow.startBinding) {
|
||||
const startBinding = arrow.startBinding;
|
||||
const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap);
|
||||
const allHits = getAllHoveredElementAtPoint(
|
||||
arrow,
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (allHits.find((el) => el.id === startBinding.elementId)) {
|
||||
const otherElement = elementsMap.get(
|
||||
@@ -468,9 +474,9 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
|
||||
let other: BindingStrategy = { mode: undefined };
|
||||
|
||||
const isMultiPoint = arrow.points.length > 2;
|
||||
const hit = getHoveredElementForBinding(point, elements, elementsMap);
|
||||
const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap);
|
||||
const isOverlapping = oppositeBinding
|
||||
? getAllHoveredElementAtPoint(point, elements, elementsMap).some(
|
||||
? getAllHoveredElementAtPoint(arrow, point, elements, elementsMap).some(
|
||||
(el) => el.id === oppositeBinding.elementId,
|
||||
)
|
||||
: false;
|
||||
@@ -695,10 +701,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
elementsMap,
|
||||
);
|
||||
const hit = getHoveredElementForBinding(
|
||||
arrow,
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
appState.zoom,
|
||||
);
|
||||
const pointInElement =
|
||||
hit &&
|
||||
@@ -734,12 +741,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
});
|
||||
|
||||
// Handle outside-outside binding to the same element
|
||||
if (otherBinding && otherBinding.elementId === hit?.id) {
|
||||
invariant(
|
||||
!opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
|
||||
"appState.selectedLinearElement.initialState.origin must be defined for new arrows",
|
||||
);
|
||||
|
||||
if (
|
||||
otherBinding &&
|
||||
otherBinding.elementId === hit?.id &&
|
||||
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin)
|
||||
) {
|
||||
return {
|
||||
start: {
|
||||
mode: "inside",
|
||||
@@ -1020,6 +1026,7 @@ export const bindBindingElement = (
|
||||
mode: BindMode,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
focusPoint?: GlobalPoint,
|
||||
shouldSnapToOutline = true,
|
||||
): void => {
|
||||
@@ -1036,6 +1043,7 @@ export const bindBindingElement = (
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
shouldSnapToOutline,
|
||||
),
|
||||
};
|
||||
@@ -1260,6 +1268,7 @@ const updateArrowBindings = (
|
||||
strategy[strategyName].mode,
|
||||
strategyName,
|
||||
scene,
|
||||
appState.zoom,
|
||||
strategy[strategyName].focusPoint,
|
||||
);
|
||||
}
|
||||
@@ -1369,6 +1378,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
customIntersector?: LineSegment<GlobalPoint>,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
@@ -1401,7 +1411,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
startOrEnd === "start" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
const bindingGap = getBindingGap(bindableElement, arrowElement);
|
||||
const bindingGap = getBindingGap(bindableElement);
|
||||
const aabb = aabbForElement(bindableElement, elementsMap);
|
||||
const bindableCenter = getCenterForBounds(aabb);
|
||||
|
||||
@@ -1411,7 +1421,13 @@ export const bindPointToSnapToElementOutline = (
|
||||
headingForPointFromElement(bindableElement, aabb, point),
|
||||
);
|
||||
const snapPoint = isMidpointSnappingEnabled
|
||||
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
|
||||
? getSnapOutlineMidPoint(
|
||||
edgePoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
zoom,
|
||||
arrowElement,
|
||||
)
|
||||
: undefined;
|
||||
const resolved = snapPoint || point;
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
@@ -1456,7 +1472,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
anotherIntersector,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
BASE_BINDING_GAP,
|
||||
).sort(pointDistanceSq)[0];
|
||||
}
|
||||
} else {
|
||||
@@ -1515,7 +1531,7 @@ export const avoidRectangularCorner = (
|
||||
-bindTarget.angle as Radians,
|
||||
);
|
||||
|
||||
const bindingGap = getBindingGap(bindTarget, arrowElement);
|
||||
const bindingGap = getBindingGap(bindTarget);
|
||||
|
||||
if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) {
|
||||
// Top left
|
||||
@@ -1593,121 +1609,6 @@ export const avoidRectangularCorner = (
|
||||
return p;
|
||||
};
|
||||
|
||||
export const snapToMid = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
tolerance: number = 0.05,
|
||||
arrowElement?: ExcalidrawArrowElement,
|
||||
): GlobalPoint | undefined => {
|
||||
const { x, y, width, height, angle } = bindTarget;
|
||||
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
|
||||
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
const verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||
|
||||
// Too close to the center makes it hard to resolve direction precisely
|
||||
if (pointDistance(center, nonRotated) < bindingGap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(center[0], y - bindingGap),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (bindTarget.type === "diamond") {
|
||||
const distance = bindingGap;
|
||||
const topLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const topRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const bottomLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
const bottomRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(topLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(topRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topRight, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomRight, center, angle);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractBinding = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
startOrEnd: "startBinding" | "endBinding",
|
||||
@@ -1808,7 +1709,7 @@ export const updateBoundPoint = (
|
||||
otherBindable,
|
||||
elementsMap,
|
||||
intersector,
|
||||
getBindingGap(otherBindable, arrow),
|
||||
getBindingGap(otherBindable),
|
||||
).sort(
|
||||
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
|
||||
)[0];
|
||||
@@ -1818,7 +1719,7 @@ export const updateBoundPoint = (
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
intersector,
|
||||
getBindingGap(bindableElement, arrow),
|
||||
getBindingGap(bindableElement),
|
||||
).sort(
|
||||
(a, b) =>
|
||||
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
|
||||
@@ -1846,7 +1747,7 @@ export const updateBoundPoint = (
|
||||
element: otherBindable,
|
||||
point: outlinePoint,
|
||||
elementsMap,
|
||||
threshold: getBindingGap(otherBindable, arrow),
|
||||
threshold: getBindingGap(otherBindable),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
) {
|
||||
@@ -1907,6 +1808,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
shouldSnapToOutline = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
@@ -1922,6 +1824,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
@@ -59,11 +59,12 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
import { getBindingGap, maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import { hasBackground } from "./comparisons";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
@@ -83,7 +84,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
}
|
||||
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isIframeLikeElement(element) ||
|
||||
isTextElement(element);
|
||||
@@ -252,25 +253,20 @@ export const hitElementBoundText = (
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
const bindingBorderTest = (
|
||||
const bindableElementBorderDistanceIfClose = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
|
||||
) => {
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const [x, y] = point;
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
@@ -281,33 +277,29 @@ const bindingBorderTest = (
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
if (!pointInsideBounds(point, enclosingFrameBounds)) {
|
||||
return -Infinity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
const distance = distanceToElement(element, elementsMap, point);
|
||||
if (isPointInElement(point, element, elementsMap)) {
|
||||
return distance;
|
||||
}
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
return distance > tolerance ? -Infinity : -distance;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
arrow: { elbowed: boolean },
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// We need to do hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
@@ -320,11 +312,20 @@ export const getAllHoveredElementAtPoint = (
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
hitElementItself({
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
threshold: tolerance ?? getBindingGap(element),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
if (
|
||||
hasBackground(element.type) &&
|
||||
!isTransparent(element.backgroundColor)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -334,82 +335,92 @@ export const getAllHoveredElementAtPoint = (
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
arrow: { elbowed: boolean },
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
zoom?: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
tolerance,
|
||||
);
|
||||
type Candidate = {
|
||||
element: NonDeleted<ExcalidrawBindableElement>;
|
||||
distance: number;
|
||||
overlapPercent?: number;
|
||||
relativeArea?: number;
|
||||
};
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
export const getHoveredElementForFocusPoint = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): ExcalidrawBindableElement | null => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
const candidates: Candidate[] = [];
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
if (!isBindableElement(element, false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const maxDistance = maxBindingDistance_simple(zoom);
|
||||
const distance = bindableElementBorderDistanceIfClose(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
maxDistance,
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
if (distance > -maxDistance) {
|
||||
candidates.push({ element, distance });
|
||||
|
||||
if (!isTransparent(element.backgroundColor) && distance >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].element;
|
||||
}
|
||||
|
||||
const distanceFilteredCandidateElements = candidateElements
|
||||
// Resolve by distance
|
||||
.filter(
|
||||
(el) =>
|
||||
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
|
||||
isPointInElement(point, el, elementsMap),
|
||||
);
|
||||
const closestElements = candidates.sort(
|
||||
(a, b) => Math.abs(a.distance) - Math.abs(b.distance),
|
||||
);
|
||||
|
||||
if (distanceFilteredCandidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidate = closestElements[0];
|
||||
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap);
|
||||
const candidateArea = Math.max(
|
||||
0.00001,
|
||||
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
|
||||
);
|
||||
const overlaps = closestElements
|
||||
.map((c) => {
|
||||
if (c.element === candidate.element) {
|
||||
return { ...c, overlapPercent: 0, relativeArea: 1 };
|
||||
}
|
||||
|
||||
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
|
||||
const overlapX1 = x1 > cx1 && x1 < cx2 ? x1 : cx1;
|
||||
const overlapY1 = y1 > cy1 && y1 < cy2 ? y1 : cy1;
|
||||
const overlapX2 = x2 < cx2 && x2 > cx1 ? x2 : cx2;
|
||||
const overlapY2 = y2 < cy2 && y2 > cy1 ? y2 : cy2;
|
||||
const overlapWdith =
|
||||
overlapX1 !== cx1 || overlapX2 !== cx2 ? overlapX2 - overlapX1 : 0;
|
||||
const overlapHeight =
|
||||
overlapY1 !== cy1 || overlapY2 !== cy2 ? overlapY2 - overlapY1 : 0;
|
||||
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
|
||||
const overlapPercent = Math.abs(overlapHeight * overlapWdith) / area;
|
||||
|
||||
return {
|
||||
...c,
|
||||
overlapPercent,
|
||||
relativeArea:
|
||||
overlapPercent === 0 ? 1 : Math.min(area / candidateArea, 1),
|
||||
};
|
||||
})
|
||||
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
|
||||
|
||||
return candidate.distance >= 0 && overlaps.length > 0
|
||||
? overlaps[0].element
|
||||
: candidate.element;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { getMinTextElementWidth } from "./textMeasurements";
|
||||
import {
|
||||
isArrowElement,
|
||||
isArrowElement as isBindingElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
@@ -108,19 +108,7 @@ export const dragSelectedElements = (
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
const isArrow = !isArrowElement(element);
|
||||
const isStartBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.startBinding
|
||||
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: false);
|
||||
const isEndBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.endBinding
|
||||
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: false);
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
if (!isBindingElement(element)) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
// skip arrow labels since we calculate its position during render
|
||||
@@ -143,7 +131,6 @@ export const dragSelectedElements = (
|
||||
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||
// is the single element being dragged to avoid accidentally unbinding
|
||||
// the arrow when the user just wants to select it.
|
||||
|
||||
elementsToUpdate.size > 1 ||
|
||||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||
DRAGGING_THRESHOLD ||
|
||||
@@ -151,9 +138,12 @@ export const dragSelectedElements = (
|
||||
) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
const shouldUnbindStart =
|
||||
element.startBinding && !isStartBoundElementSelected;
|
||||
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||
const shouldUnbindStart = element.startBinding
|
||||
? !elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: true;
|
||||
const shouldUnbindEnd = element.endBinding
|
||||
? !elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: true;
|
||||
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||
// have weird situations, like 0 lenght arrow when the user moves
|
||||
|
||||
@@ -111,6 +111,9 @@ export const duplicateElements = (
|
||||
* user interaction.
|
||||
*/
|
||||
type: "everything";
|
||||
// TODO remove/review this once we add frame children order migration
|
||||
// and invariant checks
|
||||
preserveFrameChildrenOrder?: boolean;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
@@ -170,6 +173,8 @@ export const duplicateElements = (
|
||||
opts.type === "in-place"
|
||||
? opts.idsOfElementsToDuplicate
|
||||
: new Map(elements.map((el) => [el.id, el]));
|
||||
const preserveFrameChildrenOrder =
|
||||
opts.type === "everything" && opts.preserveFrameChildrenOrder;
|
||||
|
||||
// For sanity
|
||||
if (opts.type === "in-place") {
|
||||
@@ -250,6 +255,9 @@ export const duplicateElements = (
|
||||
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
|
||||
};
|
||||
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const frameIdsToDuplicate = new Set(
|
||||
elements
|
||||
.filter(
|
||||
@@ -274,7 +282,7 @@ export const duplicateElements = (
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
||||
(element) =>
|
||||
isFrameLikeElement(element)
|
||||
isFrameLikeElement(element) && !preserveFrameChildrenOrder
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
@@ -290,13 +298,25 @@ export const duplicateElements = (
|
||||
// frame duplication
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
||||
if (
|
||||
!preserveFrameChildrenOrder &&
|
||||
element.frameId &&
|
||||
frameIdsToDuplicate.has(element.frameId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
const frameId = element.id;
|
||||
|
||||
if (preserveFrameChildrenOrder) {
|
||||
insertBeforeOrAfterIndex(
|
||||
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
|
||||
copyElements(element),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
|
||||
@@ -30,8 +30,7 @@ import {
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
getBindingGap,
|
||||
maxBindingDistance_simple,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
BASE_BINDING_GAP,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
@@ -318,6 +317,7 @@ const handleSegmentRelease = (
|
||||
...rest
|
||||
} = getElbowArrowData(
|
||||
{
|
||||
...arrow,
|
||||
x,
|
||||
y,
|
||||
startBinding,
|
||||
@@ -1041,6 +1041,7 @@ export const updateElbowArrowPoints = (
|
||||
...rest
|
||||
} = getElbowArrowData(
|
||||
{
|
||||
...arrow,
|
||||
x: arrow.x,
|
||||
y: arrow.y,
|
||||
startBinding,
|
||||
@@ -1190,15 +1191,7 @@ export const updateElbowArrowPoints = (
|
||||
* - hoveredEndElement: The element being hovered over at the end point.
|
||||
*/
|
||||
const getElbowArrowData = (
|
||||
arrow: {
|
||||
x: number;
|
||||
y: number;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
points: readonly LocalPoint[];
|
||||
},
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
options?: {
|
||||
@@ -1223,6 +1216,7 @@ const getElbowArrowData = (
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
arrow,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
@@ -1230,6 +1224,7 @@ const getElbowArrowData = (
|
||||
) || null;
|
||||
hoveredEndElement =
|
||||
getHoveredElement(
|
||||
arrow,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
@@ -1256,6 +1251,7 @@ const getElbowArrowData = (
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
options?.zoom || ({ value: 1 } as AppState["zoom"]),
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
@@ -1273,6 +1269,7 @@ const getElbowArrowData = (
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
options?.zoom || ({ value: 1 } as AppState["zoom"]),
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
@@ -1314,8 +1311,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||
? getBindingGap(hoveredStartElement) * 6
|
||||
: getBindingGap(hoveredStartElement) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1327,8 +1324,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||
? getBindingGap(hoveredEndElement) * 6
|
||||
: getBindingGap(hoveredEndElement) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1375,8 +1372,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? BASE_BINDING_GAP * 6
|
||||
: BASE_BINDING_GAP * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1391,8 +1388,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? BASE_BINDING_GAP * 6
|
||||
: BASE_BINDING_GAP * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
@@ -2218,6 +2215,7 @@ const getGlobalPoint = (
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
zoom: AppState["zoom"],
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
@@ -2231,6 +2229,7 @@ const getGlobalPoint = (
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
@@ -2279,16 +2278,18 @@ const getBindPointHeading = (
|
||||
);
|
||||
|
||||
const getHoveredElement = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
return getHoveredElementForBinding(
|
||||
arrow,
|
||||
origPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(zoom),
|
||||
zoom,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -452,8 +452,16 @@ const createBindingArrow = (
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
appState.zoom,
|
||||
);
|
||||
bindBindingElement(
|
||||
bindingArrow,
|
||||
endBindingElement,
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
appState.zoom,
|
||||
);
|
||||
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
validateOrderKey,
|
||||
generateNKeysBetween,
|
||||
} from "@excalidraw/fractional-indexing";
|
||||
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
@@ -382,6 +385,13 @@ const isValidFractionalIndex = (
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format validation
|
||||
validateOrderKey(index);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predecessor && successor) {
|
||||
return predecessor < index && index < successor;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
doBoundsIntersect,
|
||||
getElementBounds,
|
||||
boundsContainBounds,
|
||||
} from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import {
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
@@ -101,8 +103,9 @@ export const isElementContainingFrame = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return getElementsWithinSelection([frame], element, elementsMap).some(
|
||||
(e) => e.id === frame.id,
|
||||
return boundsContainBounds(
|
||||
getElementBounds(element, elementsMap),
|
||||
getElementBounds(frame, elementsMap),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -489,10 +492,44 @@ export const filterElementsEligibleAsFrameChildren = (
|
||||
return eligibleElements;
|
||||
};
|
||||
|
||||
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
|
||||
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
|
||||
|
||||
for (const element of elements) {
|
||||
if (isFrameLikeElement(element) || !element.frameId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (commonFrameId === undefined) {
|
||||
commonFrameId = element.frameId;
|
||||
} else if (commonFrameId !== element.frameId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return commonFrameId ?? null;
|
||||
};
|
||||
|
||||
export const getFrameChildrenInsertionIndex = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frameId: ExcalidrawFrameLikeElement["id"],
|
||||
): number | null => {
|
||||
for (let index = elements.length - 1; index >= 0; index--) {
|
||||
const element = elements[index];
|
||||
|
||||
if (element.id === frameId) {
|
||||
return index;
|
||||
} else if (element.frameId === frameId) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retains (or repairs for target frame) the ordering invriant where children
|
||||
* elements come right before the parent frame:
|
||||
* [el, el, child, child, frame, el]
|
||||
* Adds elements and their bound elements to frame. Reorders added elements to
|
||||
* be just below frame, or just above its highest child (whichever is higher).
|
||||
*
|
||||
* @returns mutated allElements (same data structure)
|
||||
*/
|
||||
@@ -500,19 +537,11 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): T => {
|
||||
const elementsMap = arrayToMap(allElements);
|
||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||
for (const element of allElements.values()) {
|
||||
if (element.frameId === frame.id) {
|
||||
currTargetFrameChildrenMap.set(element.id, true);
|
||||
}
|
||||
}
|
||||
const commonFrameId = getCommonFrameId(elementsToAdd);
|
||||
|
||||
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
|
||||
|
||||
const finalElementsToAdd: ExcalidrawElement[] = [];
|
||||
const finalElementsToAdd = new Set<ExcalidrawElement>();
|
||||
|
||||
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
|
||||
|
||||
@@ -523,7 +552,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
}
|
||||
|
||||
// - add bound text elements if not already in the array
|
||||
// - filter out elements that are already in the frame
|
||||
// - keep elements already in the frame so mixed selections can be reordered
|
||||
// together
|
||||
for (const element of omitGroupsContainingFrameLikes(
|
||||
allElements,
|
||||
elementsToAdd,
|
||||
@@ -536,38 +566,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the element is already in another frame (which is also in elementsToAdd),
|
||||
// it means that frame and children are selected at the same time
|
||||
// => keep original frame membership, do not add to the target frame
|
||||
if (
|
||||
element.frameId &&
|
||||
appState.selectedElementIds[element.id] &&
|
||||
appState.selectedElementIds[element.frameId]
|
||||
) {
|
||||
if (element.frameId && element.frameId !== frame.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currTargetFrameChildrenMap.has(element.id)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
finalElementsToAdd.add(element);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (
|
||||
boundTextElement &&
|
||||
!suppliedElementsToAddSet.has(boundTextElement.id) &&
|
||||
!currTargetFrameChildrenMap.has(boundTextElement.id)
|
||||
) {
|
||||
finalElementsToAdd.push(boundTextElement);
|
||||
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
|
||||
finalElementsToAdd.add(boundTextElement);
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of finalElementsToAdd) {
|
||||
mutateElement(element, elementsMap, {
|
||||
frameId: frame.id,
|
||||
});
|
||||
// we don't always need to update the element if it's already in the frame,
|
||||
// but we still need to accumulate in finalElementsToAdd so we potentially
|
||||
// reorder them if added together
|
||||
if (element.frameId !== frame.id) {
|
||||
mutateElement(element, elementsMap, {
|
||||
frameId: frame.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allElements;
|
||||
// (re)order elements to be just below the frame,
|
||||
// or just above the highest child if that is higher
|
||||
// (latter case is denormalized order until we migrate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (
|
||||
!finalElementsToAdd.size ||
|
||||
// if all elements to add already belong to the frame, then we don't want to
|
||||
// reorder (case: we're dragging element children within the frame)
|
||||
commonFrameId === frame.id
|
||||
) {
|
||||
return allElements;
|
||||
}
|
||||
|
||||
const otherElements = Array.from(allElements.values()).filter(
|
||||
(element) => !finalElementsToAdd.has(element),
|
||||
);
|
||||
const insertionIndex = getFrameChildrenInsertionIndex(
|
||||
otherElements,
|
||||
frame.id,
|
||||
);
|
||||
|
||||
if (insertionIndex === null) {
|
||||
return allElements;
|
||||
}
|
||||
|
||||
const reorderedElements = [
|
||||
...otherElements.slice(0, insertionIndex),
|
||||
...finalElementsToAdd,
|
||||
...otherElements.slice(insertionIndex),
|
||||
];
|
||||
|
||||
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
|
||||
|
||||
return (
|
||||
Array.isArray(allElements)
|
||||
? reorderedElements
|
||||
: new Map(reorderedElements.map((element) => [element.id, element]))
|
||||
) as T;
|
||||
};
|
||||
|
||||
export const removeElementsFromFrame = (
|
||||
@@ -621,13 +681,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
||||
allElements: readonly T[],
|
||||
nextElementsInFrame: ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
app: AppClassProperties,
|
||||
): T[] => {
|
||||
return addElementsToFrame(
|
||||
removeAllElementsFromFrame(allElements, frame),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
app.state,
|
||||
).slice();
|
||||
};
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ export * from "./selection";
|
||||
export * from "./shape";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./snapping";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./textElement";
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
line,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
} from "@excalidraw/math";
|
||||
@@ -30,9 +29,6 @@ import {
|
||||
isPathALoop,
|
||||
moveArrowAboveBindable,
|
||||
projectFixedPointOntoDiagonal,
|
||||
snapLinearElementPoint,
|
||||
snapToDiscreteAngle,
|
||||
type SnapLine,
|
||||
type Store,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -52,8 +48,6 @@ import {
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
getBindingStrategyForDraggingBindingElementEndpoints,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
snapToMid,
|
||||
updateBoundPoint,
|
||||
} from "./binding";
|
||||
import {
|
||||
@@ -61,7 +55,6 @@ import {
|
||||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -300,10 +293,7 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): Pick<
|
||||
AppState,
|
||||
"suggestedBinding" | "selectedLinearElement" | "snapLines"
|
||||
> | null {
|
||||
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const { elementId } = linearElementEditor;
|
||||
@@ -320,30 +310,40 @@ export class LinearElementEditor {
|
||||
linearElementEditor.customLineAngle ??
|
||||
determineCustomLinearAngle(pivotPoint, element.points[idx]);
|
||||
|
||||
const { point: newDraggingPointPosition, snapLines } =
|
||||
LinearElementEditor._getSnappedPointForLinearElement({
|
||||
app,
|
||||
event,
|
||||
elements,
|
||||
elementsMap,
|
||||
// Determine if point movement should happen and how much
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
if (shouldRotateWithDiscreteAngle(event)) {
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
pointIndex: idx,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
pointerOffset: linearElementEditor.pointerOffset,
|
||||
referencePoint: shouldRotateWithDiscreteAngle(event)
|
||||
? pivotPoint
|
||||
: null,
|
||||
selectedPointsIndices: [idx],
|
||||
elementsMap,
|
||||
pivotPoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
});
|
||||
);
|
||||
const target = pointFrom<LocalPoint>(
|
||||
width + pivotPoint[0],
|
||||
height + pivotPoint[1],
|
||||
);
|
||||
|
||||
const deltaX = newDraggingPointPosition[0] - point[0];
|
||||
const deltaY = newDraggingPointPosition[1] - point[1];
|
||||
deltaX = target[0] - point[0];
|
||||
deltaY = target[1] - point[1];
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
deltaX = newDraggingPointPosition[0] - point[0];
|
||||
deltaY = newDraggingPointPosition[1] - point[1];
|
||||
}
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
[idx],
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -381,24 +381,25 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element)) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Avoid state updates if not absolutely necessary
|
||||
if (
|
||||
app.state.selectedLinearElement?.customLineAngle === customLineAngle &&
|
||||
app.state.snapLines.length === 0 &&
|
||||
snapLines.length === 0 &&
|
||||
linearElementEditor.initialState.altFocusPoint &&
|
||||
(!suggestedBinding ||
|
||||
isShallowEqual(app.state.suggestedBinding ?? [], suggestedBinding))
|
||||
@@ -437,7 +438,6 @@ export class LinearElementEditor {
|
||||
return {
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBinding,
|
||||
snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -447,10 +447,7 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): Pick<
|
||||
AppState,
|
||||
"suggestedBinding" | "selectedLinearElement" | "snapLines"
|
||||
> | null {
|
||||
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const { elbowed, elementId, initialState } = linearElementEditor;
|
||||
@@ -491,13 +488,14 @@ export class LinearElementEditor {
|
||||
selectedPointsIndices,
|
||||
)}) points(0..${
|
||||
element.points.length - 1
|
||||
}) lastClickedPoint(${lastClickedPoint})`,
|
||||
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
|
||||
);
|
||||
|
||||
// Fall back to the actual last point as a last resort.
|
||||
lastClickedPoint = element.points.length - 1;
|
||||
}
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[lastClickedPoint];
|
||||
// The adjacent point to the one dragged point
|
||||
const pivotPoint =
|
||||
@@ -511,31 +509,39 @@ export class LinearElementEditor {
|
||||
element.points.length - 1,
|
||||
);
|
||||
|
||||
const { point: newDraggingPointPosition, snapLines } =
|
||||
LinearElementEditor._getSnappedPointForLinearElement({
|
||||
app,
|
||||
event,
|
||||
elements,
|
||||
elementsMap,
|
||||
// Determine if point movement should happen and how much
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) {
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
pointIndex: lastClickedPoint,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
pointerOffset: linearElementEditor.pointerOffset,
|
||||
referencePoint:
|
||||
shouldRotateWithDiscreteAngle(event) && singlePointDragged
|
||||
? pivotPoint
|
||||
: null,
|
||||
selectedPointsIndices,
|
||||
elementsMap,
|
||||
pivotPoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
});
|
||||
|
||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||
);
|
||||
const target = pointFrom<LocalPoint>(
|
||||
width + pivotPoint[0],
|
||||
height + pivotPoint[1],
|
||||
);
|
||||
deltaX = target[0] - draggingPoint[0];
|
||||
deltaY = target[1] - draggingPoint[1];
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||
deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||
}
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
selectedPointsIndices,
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -574,19 +580,22 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attached text might need to update if arrow dimensions change
|
||||
@@ -670,7 +679,6 @@ export class LinearElementEditor {
|
||||
return {
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBinding,
|
||||
snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1175,10 +1183,7 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
app: AppClassProperties,
|
||||
): {
|
||||
editingLinearElement: LinearElementEditor;
|
||||
snapLines: readonly SnapLine[];
|
||||
} | null {
|
||||
): LinearElementEditor | null {
|
||||
const appState = app.state;
|
||||
if (!appState.selectedLinearElement?.isEditing) {
|
||||
return null;
|
||||
@@ -1187,10 +1192,7 @@ export class LinearElementEditor {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return {
|
||||
editingLinearElement: appState.selectedLinearElement,
|
||||
snapLines: appState.snapLines,
|
||||
};
|
||||
return appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -1202,37 +1204,36 @@ export class LinearElementEditor {
|
||||
}
|
||||
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||
? {
|
||||
editingLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
},
|
||||
snapLines: [],
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
}
|
||||
: {
|
||||
editingLinearElement: appState.selectedLinearElement,
|
||||
snapLines: [],
|
||||
};
|
||||
: appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
const anchor = points[points.length - 2];
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const { point: newPoint, snapLines } =
|
||||
LinearElementEditor._getSnappedPointForLinearElement({
|
||||
app,
|
||||
event,
|
||||
elements,
|
||||
elementsMap,
|
||||
let newPoint: LocalPoint;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||
const anchor = points[points.length - 2];
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
pointIndex: points.length - 1,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
pointerOffset: appState.selectedLinearElement.pointerOffset,
|
||||
referencePoint:
|
||||
shouldRotateWithDiscreteAngle(event) && points.length >= 2
|
||||
? anchor
|
||||
: null,
|
||||
selectedPointsIndices: [points.length - 1],
|
||||
});
|
||||
elementsMap,
|
||||
anchor,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
newPoint = pointFrom(width + anchor[0], height + anchor[1]);
|
||||
} else {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
);
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(
|
||||
@@ -1240,7 +1241,7 @@ export class LinearElementEditor {
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
points.length - 1,
|
||||
element.points.length - 1,
|
||||
{
|
||||
point: newPoint,
|
||||
},
|
||||
@@ -1250,13 +1251,9 @@ export class LinearElementEditor {
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
}
|
||||
|
||||
return {
|
||||
editingLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
},
|
||||
snapLines,
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1282,53 +1279,18 @@ export class LinearElementEditor {
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
options: {
|
||||
dragOffset?: { x: number; y: number };
|
||||
excludePointsIndices?: readonly number[];
|
||||
} = {},
|
||||
): GlobalPoint[] {
|
||||
const { dragOffset, excludePointsIndices } = options;
|
||||
|
||||
if (!element.points || element.points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
let elementX = element.x;
|
||||
let elementY = element.y;
|
||||
|
||||
if (dragOffset) {
|
||||
elementX += dragOffset.x;
|
||||
elementY += dragOffset.y;
|
||||
}
|
||||
|
||||
const globalPoints: GlobalPoint[] = [];
|
||||
|
||||
for (let i = 0; i < element.points.length; i++) {
|
||||
// Skip the point being edited if specified
|
||||
if (
|
||||
excludePointsIndices?.length &&
|
||||
excludePointsIndices.find((index) => index === i) !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const p = element.points[i];
|
||||
const globalX = elementX + p[0];
|
||||
const globalY = elementY + p[1];
|
||||
|
||||
const rotated = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(globalX, globalY),
|
||||
return element.points.map((p) => {
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
globalPoints.push(rotated);
|
||||
}
|
||||
|
||||
return globalPoints;
|
||||
});
|
||||
}
|
||||
|
||||
static getPointAtIndexGlobalCoordinates(
|
||||
@@ -1882,222 +1844,6 @@ export class LinearElementEditor {
|
||||
);
|
||||
}
|
||||
|
||||
private static _getPointPlacementGridSize(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
app: AppClassProperties,
|
||||
event: Pick<KeyboardEvent | PointerEvent, typeof KEYS.CTRL_OR_CMD>,
|
||||
): NullableGridSize {
|
||||
return event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize();
|
||||
}
|
||||
|
||||
private static _shouldSkipExternalSnapForBindableTarget({
|
||||
appState,
|
||||
elements,
|
||||
elementsMap,
|
||||
element,
|
||||
pointIndex,
|
||||
scenePoint,
|
||||
selectedPointsIndices,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[];
|
||||
elementsMap: NonDeletedSceneElementsMap;
|
||||
element: NonDeleted<ExcalidrawLinearElement>;
|
||||
pointIndex: number;
|
||||
scenePoint: GlobalPoint;
|
||||
selectedPointsIndices?: readonly number[];
|
||||
}) {
|
||||
if (
|
||||
isElbowArrow(element) ||
|
||||
!isBindingElement(element) ||
|
||||
!isBindingEnabled(appState) ||
|
||||
selectedPointsIndices?.length !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pointIndex !== 0 && pointIndex !== element.points.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!getHoveredElementForBinding(
|
||||
scenePoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
);
|
||||
}
|
||||
|
||||
private static _getSnappedPointForLinearElement({
|
||||
app,
|
||||
event,
|
||||
elements,
|
||||
elementsMap,
|
||||
element,
|
||||
pointIndex,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
pointerOffset,
|
||||
referencePoint,
|
||||
selectedPointsIndices,
|
||||
customLineAngle,
|
||||
}: {
|
||||
app: AppClassProperties;
|
||||
event: PointerEvent | React.PointerEvent<HTMLCanvasElement>;
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[];
|
||||
elementsMap: NonDeletedSceneElementsMap;
|
||||
element: NonDeleted<ExcalidrawLinearElement>;
|
||||
pointIndex: number;
|
||||
scenePointerX: number;
|
||||
scenePointerY: number;
|
||||
pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
referencePoint?: LocalPoint | null;
|
||||
selectedPointsIndices?: readonly number[];
|
||||
customLineAngle?: number | null;
|
||||
}): {
|
||||
point: LocalPoint;
|
||||
snapLines: SnapLine[];
|
||||
} {
|
||||
const gridSize = LinearElementEditor._getPointPlacementGridSize(
|
||||
element,
|
||||
app,
|
||||
event,
|
||||
);
|
||||
|
||||
if (referencePoint) {
|
||||
const referencePointCoords =
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
referencePoint,
|
||||
elementsMap,
|
||||
);
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
let { width: dxFromReference, height: dyFromReference } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
referencePointCoords[0],
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
customLineAngle ?? undefined,
|
||||
);
|
||||
|
||||
const effectiveGridX = referencePointCoords[0] + dxFromReference;
|
||||
const effectiveGridY = referencePointCoords[1] + dyFromReference;
|
||||
|
||||
let snapLines: SnapLine[] = [];
|
||||
const shouldSkipExternalSnap =
|
||||
LinearElementEditor._shouldSkipExternalSnapForBindableTarget({
|
||||
appState: app.state,
|
||||
elements,
|
||||
elementsMap,
|
||||
element,
|
||||
pointIndex,
|
||||
scenePoint: pointFrom<GlobalPoint>(effectiveGridX, effectiveGridY),
|
||||
selectedPointsIndices,
|
||||
});
|
||||
|
||||
if (!isElbowArrow(element)) {
|
||||
const { snapOffset, snapLines: nextSnapLines } = snapLinearElementPoint(
|
||||
elements,
|
||||
element,
|
||||
pointFrom<GlobalPoint>(effectiveGridX, effectiveGridY),
|
||||
app,
|
||||
event,
|
||||
elementsMap,
|
||||
{
|
||||
includeExternalPoints: !shouldSkipExternalSnap,
|
||||
includeSelfPoints: true,
|
||||
selectedPointsIndices,
|
||||
},
|
||||
);
|
||||
|
||||
snapLines = nextSnapLines;
|
||||
|
||||
if (nextSnapLines.length > 0) {
|
||||
const result = snapToDiscreteAngle(
|
||||
nextSnapLines,
|
||||
line(
|
||||
pointFrom(effectiveGridX, effectiveGridY),
|
||||
pointFrom(referencePointCoords[0], referencePointCoords[1]),
|
||||
),
|
||||
pointFrom(gridX, gridY),
|
||||
referencePointCoords,
|
||||
);
|
||||
|
||||
if (result.snapLines.length > 0) {
|
||||
dxFromReference = result.dxFromReference;
|
||||
dyFromReference = result.dyFromReference;
|
||||
snapLines = result.snapLines;
|
||||
} else {
|
||||
dxFromReference =
|
||||
effectiveGridX + snapOffset.x - referencePointCoords[0];
|
||||
dyFromReference =
|
||||
effectiveGridY + snapOffset.y - referencePointCoords[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
pointFrom(dxFromReference, dyFromReference),
|
||||
pointFrom(0, 0),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
return {
|
||||
point: pointFrom(
|
||||
referencePoint[0] + rotatedX,
|
||||
referencePoint[1] + rotatedY,
|
||||
),
|
||||
snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
const originalPointerX = scenePointerX - pointerOffset.x;
|
||||
const originalPointerY = scenePointerY - pointerOffset.y;
|
||||
const shouldSkipExternalSnap =
|
||||
LinearElementEditor._shouldSkipExternalSnapForBindableTarget({
|
||||
appState: app.state,
|
||||
elements,
|
||||
elementsMap,
|
||||
element,
|
||||
pointIndex,
|
||||
scenePoint: pointFrom<GlobalPoint>(originalPointerX, originalPointerY),
|
||||
selectedPointsIndices,
|
||||
});
|
||||
|
||||
const { snapOffset, snapLines } = snapLinearElementPoint(
|
||||
elements,
|
||||
element,
|
||||
pointFrom(originalPointerX, originalPointerY),
|
||||
app,
|
||||
event,
|
||||
elementsMap,
|
||||
{
|
||||
includeExternalPoints: !shouldSkipExternalSnap,
|
||||
includeSelfPoints: true,
|
||||
selectedPointsIndices,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
point: LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
originalPointerX + snapOffset.x,
|
||||
originalPointerY + snapOffset.y,
|
||||
gridSize,
|
||||
),
|
||||
snapLines,
|
||||
};
|
||||
}
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
@@ -2395,16 +2141,17 @@ const pointDraggingUpdates = (
|
||||
): {
|
||||
positions: PointsPositionUpdates;
|
||||
updates?: PointMoveOtherUpdates;
|
||||
hit?: ExcalidrawBindableElement | null;
|
||||
} => {
|
||||
const naiveDraggingPoints = new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
// NOTE: Avoid stale point index issue potentially caused by elbow
|
||||
// arrows unpredictably changing the number of points during dragging
|
||||
const point = element.points[pointIndex] ?? element.points.at(-1);
|
||||
return [
|
||||
pointIndex,
|
||||
{
|
||||
point: pointFrom<LocalPoint>(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
),
|
||||
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
|
||||
isDragging: true,
|
||||
},
|
||||
];
|
||||
@@ -2452,13 +2199,15 @@ const pointDraggingUpdates = (
|
||||
? {
|
||||
element: suggestedBindingElement,
|
||||
midPoint: app.state.isMidpointSnappingEnabled
|
||||
? snapToMid(
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
? getSnapOutlineMidPoint(
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
@@ -2565,6 +2314,7 @@ const pointDraggingUpdates = (
|
||||
start.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -2604,6 +2354,7 @@ const pointDraggingUpdates = (
|
||||
end.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -2756,6 +2507,7 @@ const pointDraggingUpdates = (
|
||||
];
|
||||
}),
|
||||
),
|
||||
hit: startIsDragged ? start.element : end.element,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
isElementIntersectingFrame,
|
||||
} from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
@@ -130,13 +129,24 @@ export const getElementsWithinSelection = (
|
||||
const framesInSelection = excludeElementsInFrames
|
||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||
: null;
|
||||
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
|
||||
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
|
||||
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
|
||||
|
||||
for (const element of elements) {
|
||||
if (shouldIgnoreElementFromSelection(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track only selectable top-level group members, so ignored elements such
|
||||
// as bound text and locked elements don't affect group selection.
|
||||
const groupId = element.groupIds.at(-1);
|
||||
if (groupId) {
|
||||
if (!groups[groupId]) {
|
||||
groups[groupId] = [];
|
||||
}
|
||||
groups[groupId].push(element);
|
||||
}
|
||||
|
||||
const strokeWidth = element.strokeWidth;
|
||||
let labelAABB: Bounds | null = null;
|
||||
let elementAABB = getElementBounds(element, elementsMap);
|
||||
@@ -170,7 +180,7 @@ export const getElementsWithinSelection = (
|
||||
const associatedFrame = getContainingFrame(element, elementsMap);
|
||||
if (
|
||||
associatedFrame &&
|
||||
isElementIntersectingFrame(element, associatedFrame, elementsMap)
|
||||
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
|
||||
) {
|
||||
const frameAABB = getElementBounds(associatedFrame, elementsMap);
|
||||
elementAABB = [
|
||||
@@ -209,10 +219,9 @@ export const getElementsWithinSelection = (
|
||||
if (boundsContainBounds(selectionBounds, commonAABB)) {
|
||||
if (framesInSelection && isFrameLikeElement(element)) {
|
||||
framesInSelection.add(element.id);
|
||||
} else {
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
elementsInSelection.add(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Handle the case where the label is overlapped by the selection box
|
||||
@@ -221,7 +230,7 @@ export const getElementsWithinSelection = (
|
||||
labelAABB &&
|
||||
doBoundsIntersect(selectionBounds, labelAABB)
|
||||
) {
|
||||
elementsInSelection.push(element);
|
||||
elementsInSelection.add(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -311,7 +320,7 @@ export const getElementsWithinSelection = (
|
||||
framesInSelection.add(element.id);
|
||||
}
|
||||
|
||||
elementsInSelection.push(element);
|
||||
elementsInSelection.add(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -320,21 +329,41 @@ export const getElementsWithinSelection = (
|
||||
// as it is separately handled in App.
|
||||
}
|
||||
|
||||
elementsInSelection = framesInSelection
|
||||
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
|
||||
: elementsInSelection;
|
||||
if (framesInSelection) {
|
||||
elementsInSelection.forEach((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
elementsInSelection.delete(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
elementsInSelection = elementsInSelection.filter((element) => {
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
if (boxSelectionMode === "overlap") {
|
||||
Array.from(elementsInSelection).forEach((element) => {
|
||||
const groupId = element.groupIds.at(-1);
|
||||
const group = groupId ? groups[groupId] : null;
|
||||
|
||||
if (containingFrame) {
|
||||
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
|
||||
}
|
||||
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
|
||||
});
|
||||
} else if (boxSelectionMode === "contain") {
|
||||
elementsInSelection.forEach((element) => {
|
||||
// note: currently we only support top-level group handling since
|
||||
// we don't support box selecting while editing the group/subgroup
|
||||
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
|
||||
const groupId = element.groupIds.at(-1);
|
||||
|
||||
return true;
|
||||
});
|
||||
const group = groupId ? groups[groupId] : null;
|
||||
|
||||
return elementsInSelection;
|
||||
if (
|
||||
group &&
|
||||
!group.every((groupElement) => elementsInSelection.has(groupElement))
|
||||
) {
|
||||
elementsInSelection.delete(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// to maintain original order elements (namely for group selection)
|
||||
return elements.filter((element) => elementsInSelection.has(element));
|
||||
};
|
||||
|
||||
export const getVisibleAndNonSelectedElements = (
|
||||
|
||||
@@ -1,59 +1,56 @@
|
||||
import { arrayToMapWithIndex } from "@excalidraw/common";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
const origElements: ExcalidrawElement[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
|
||||
const orderInnerGroups = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ExcalidrawElement[] => {
|
||||
const firstGroupSig = elements[0]?.groupIds?.join("");
|
||||
const aGroup: ExcalidrawElement[] = [elements[0]];
|
||||
const bGroup: ExcalidrawElement[] = [];
|
||||
for (const element of elements.slice(1)) {
|
||||
if (element.groupIds?.join("") === firstGroupSig) {
|
||||
aGroup.push(element);
|
||||
} else {
|
||||
bGroup.push(element);
|
||||
}
|
||||
}
|
||||
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
|
||||
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
|
||||
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
|
||||
return element.groupIds[element.groupIds.length - level - 1];
|
||||
};
|
||||
|
||||
const groupHandledElements = new Map<string, true>();
|
||||
const orderLevel = (
|
||||
levelElements: readonly ExcalidrawElement[],
|
||||
level: number,
|
||||
): ExcalidrawElement[] => {
|
||||
const buckets = new Map<string, ExcalidrawElement[]>();
|
||||
// Slots preserve first-occurrence order: a groupId reserves its slot
|
||||
// the first time one of its members is seen; loose elements occupy
|
||||
// their own slot. Groups are then expanded (and recursed into) in place.
|
||||
const slots: (ExcalidrawElement | string)[] = [];
|
||||
|
||||
origElements.forEach((element, idx) => {
|
||||
if (groupHandledElements.has(element.id)) {
|
||||
return;
|
||||
}
|
||||
if (element.groupIds?.length) {
|
||||
const topGroup = element.groupIds[element.groupIds.length - 1];
|
||||
const groupElements = origElements.slice(idx).filter((element) => {
|
||||
const ret = element?.groupIds?.some((id) => id === topGroup);
|
||||
if (ret) {
|
||||
groupHandledElements.set(element!.id, true);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
for (const elem of orderInnerGroups(groupElements)) {
|
||||
sortedElements.add(elem);
|
||||
for (const element of levelElements) {
|
||||
const groupId = groupIdAtLevel(element, level);
|
||||
if (groupId === undefined) {
|
||||
slots.push(element);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
sortedElements.add(element);
|
||||
let bucket = buckets.get(groupId);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
buckets.set(groupId, bucket);
|
||||
slots.push(groupId);
|
||||
}
|
||||
bucket.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
return slots.flatMap((slot) =>
|
||||
typeof slot === "string"
|
||||
? orderLevel(buckets.get(slot)!, level + 1)
|
||||
: [slot],
|
||||
);
|
||||
};
|
||||
|
||||
// `groupIds` is stored innermost-first, so the outermost group is the
|
||||
// last entry. We recurse from level 0 (outermost) inward.
|
||||
const sortedElements = orderLevel(elements, 0);
|
||||
|
||||
// if there's a bug which resulted in losing some of the elements, return
|
||||
// original instead as that's better than losing data
|
||||
if (sortedElements.size !== elements.length) {
|
||||
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
|
||||
if (sortedElements.length !== elements.length) {
|
||||
console.error("defragmentGroups: lost some elements... bailing!");
|
||||
return elements;
|
||||
}
|
||||
|
||||
return [...sortedElements];
|
||||
return sortedElements;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
const normalizeBoundElementsOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const elementsMap = arrayToMapWithIndex(elements);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const origElements: (ExcalidrawElement | null)[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
|
||||
origElements.forEach((element, idx) => {
|
||||
if (!element) {
|
||||
return;
|
||||
for (const element of elements) {
|
||||
if (sortedElements.has(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.boundElements?.length) {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
element.boundElements.forEach((boundElement) => {
|
||||
for (const boundElement of element.boundElements) {
|
||||
const child = elementsMap.get(boundElement.id);
|
||||
if (child && boundElement.type === "text") {
|
||||
sortedElements.add(child[0]);
|
||||
origElements[child[1]] = null;
|
||||
sortedElements.add(child);
|
||||
}
|
||||
});
|
||||
} else if (element.type === "text" && element.containerId) {
|
||||
const parent = elementsMap.get(element.containerId);
|
||||
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
|
||||
// if element has a container and container lists it, skip this element
|
||||
// as it'll be taken care of by the container
|
||||
}
|
||||
} else {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
// if element has a container and container lists it, skip this element
|
||||
// as it'll be taken care of by the container
|
||||
if (
|
||||
element.type === "text" &&
|
||||
element.containerId &&
|
||||
elementsMap
|
||||
.get(element.containerId)
|
||||
?.boundElements?.some((el) => el.id === element.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sortedElements.add(element);
|
||||
}
|
||||
|
||||
// if there's a bug which resulted in losing some of the elements, return
|
||||
// original instead as that's better than losing data
|
||||
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
|
||||
export const normalizeElementOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
return normalizeBoundElementsOrder(defragmentGroups(elements));
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkOptional } from "@excalidraw/common/utility-types";
|
||||
import type { Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindBindingElement } from "./binding";
|
||||
import {
|
||||
@@ -248,6 +249,7 @@ const bindLinearElementToElement = (
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
scene: Scene,
|
||||
zoom: Zoom,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
@@ -335,6 +337,7 @@ const bindLinearElementToElement = (
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -411,6 +414,7 @@ const bindLinearElementToElement = (
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -696,6 +700,7 @@ export const convertToExcalidrawElements = (
|
||||
originalEnd,
|
||||
elementStore,
|
||||
scene,
|
||||
{ value: 1 } as Zoom,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
@@ -721,6 +726,7 @@ export const convertToExcalidrawElements = (
|
||||
end,
|
||||
elementStore,
|
||||
scene,
|
||||
{ value: 1 } as Zoom,
|
||||
);
|
||||
|
||||
elementStore.add(linearElement);
|
||||
|
||||
@@ -392,3 +392,23 @@ export const canBecomePolygon = (
|
||||
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||
);
|
||||
};
|
||||
|
||||
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
|
||||
switch (type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "arrow":
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "text":
|
||||
case "image":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+181
-56
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
clamp,
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -41,7 +42,7 @@ import { generateLinearCollisionShape } from "./shape";
|
||||
import { hitElementItself, isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
import { getBindingGap, maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
@@ -587,67 +588,193 @@ const getDiagonalsForBindableElement = (
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
const getSnappedMidpointIndexForElbowArrow = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
point: GlobalPoint,
|
||||
center: GlobalPoint,
|
||||
horizontalThreshold: number,
|
||||
verticalThreshold: number,
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const sideMidpoints =
|
||||
element.type === "diamond"
|
||||
? getDiamondBaseCorners(element).map((curve) => {
|
||||
const point = bezierEquation(curve, 0.5);
|
||||
const rotatedPoint = pointRotateRads(point, center, element.angle);
|
||||
const { x, y, width, height, angle } = element;
|
||||
const nonRotated = pointRotateRads(point, center, -angle as Radians);
|
||||
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
})
|
||||
: [
|
||||
// RIGHT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// BOTTOM midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// LEFT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// TOP midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
];
|
||||
const candidate = sideMidpoints.find(
|
||||
(midpoint) =>
|
||||
pointDistance(point, midpoint) <=
|
||||
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
|
||||
const bindingGap = getBindingGap(element);
|
||||
|
||||
if (pointDistance(center, nonRotated) < bindingGap) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
return 2;
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
return 3;
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
return 0;
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
return 1;
|
||||
} else if (element.type === "diamond") {
|
||||
const distance = bindingGap;
|
||||
const topLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const topRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const bottomLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
const bottomRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(bottomLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
pointDistance(topLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
if (
|
||||
pointDistance(topRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
const getSnappedMidpointIndexForSimpleArrow = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
horizontalThreshold: number,
|
||||
verticalThreshold: number,
|
||||
) => {
|
||||
const baseMidpoints = getAllMidpoints(element, elementsMap);
|
||||
|
||||
for (let i = 0; i < baseMidpoints.length; i++) {
|
||||
const threshold = i % 2 === 0 ? horizontalThreshold : verticalThreshold;
|
||||
|
||||
if (
|
||||
pointDistance(baseMidpoints[i], point) <= threshold &&
|
||||
!hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 0,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
);
|
||||
})
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const getAllMidpoints = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return getDiamondBaseCorners(element).map((curve) =>
|
||||
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
pointFrom(element.width, element.height / 2),
|
||||
pointFrom(element.width / 2, element.height),
|
||||
pointFrom(0, element.height / 2),
|
||||
pointFrom(element.width / 2, 0),
|
||||
].map(([x, y]) =>
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + x, element.y + y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
arrow: { elbowed: boolean },
|
||||
): GlobalPoint | undefined => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const baseMidpoints = getAllMidpoints(element, elementsMap);
|
||||
const sideMidpoints =
|
||||
element.type === "diamond"
|
||||
? baseMidpoints.map((midpoint) => {
|
||||
return pointFrom<GlobalPoint>(
|
||||
midpoint[0] + (midpoint[0] - center[0]) * 0.1,
|
||||
midpoint[1] + (midpoint[1] - center[1]) * 0.1,
|
||||
);
|
||||
})
|
||||
: baseMidpoints;
|
||||
|
||||
const TOLERANCE = 0.05;
|
||||
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
|
||||
const verticalThreshold = clamp(TOLERANCE * element.height, 5, maxDistance);
|
||||
const horizontalThreshold = clamp(TOLERANCE * element.width, 5, maxDistance);
|
||||
const idx = arrow.elbowed
|
||||
? getSnappedMidpointIndexForElbowArrow(
|
||||
element,
|
||||
point,
|
||||
center,
|
||||
horizontalThreshold,
|
||||
verticalThreshold,
|
||||
)
|
||||
: getSnappedMidpointIndexForSimpleArrow(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
horizontalThreshold,
|
||||
verticalThreshold,
|
||||
);
|
||||
|
||||
if (idx === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sideMidpoints[idx];
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
@@ -660,9 +787,6 @@ export const projectFixedPointOntoDiagonal = (
|
||||
isMidpointSnappingEnabled: boolean = true,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMidpointSnappingEnabled) {
|
||||
const sideMidPoint = getSnapOutlineMidPoint(
|
||||
@@ -670,6 +794,7 @@ export const projectFixedPointOntoDiagonal = (
|
||||
element,
|
||||
elementsMap,
|
||||
zoom,
|
||||
arrow,
|
||||
);
|
||||
if (sideMidPoint) {
|
||||
return sideMidPoint;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getElementsInGroup } from "./groups";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type {
|
||||
@@ -156,12 +155,8 @@ export const moveArrowAboveBindable = (
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
hit?: NonDeletedExcalidrawElement,
|
||||
hoveredElement: NonDeletedExcalidrawElement,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = hit
|
||||
? hit
|
||||
: getHoveredElementForBinding(point, elements, elementsMap);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import "@excalidraw/utils/test-utils";
|
||||
import { bindBindingElement } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type { Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Scene } from "../src/Scene";
|
||||
|
||||
@@ -187,8 +188,12 @@ describe("elbow arrow routing", () => {
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
API.setElements([rectangle1, rectangle2, arrow]);
|
||||
|
||||
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
|
||||
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
|
||||
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene, {
|
||||
value: 1,
|
||||
} as Zoom);
|
||||
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene, {
|
||||
value: 1,
|
||||
} as Zoom);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* eslint-disable no-lone-blocks */
|
||||
import { generateKeyBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
InvalidFractionalIndexError,
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
@@ -13,13 +12,34 @@ import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import {
|
||||
generateKeyBetween,
|
||||
validateOrderKey,
|
||||
} from "@excalidraw/fractional-indexing";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
|
||||
describe("fractional index format validation", () => {
|
||||
it("should reject malformed base62 order keys", () => {
|
||||
expect(() => validateOrderKey("a!")).toThrow();
|
||||
expect(() => validateOrderKey("a_")).toThrow();
|
||||
expect(() => validateOrderKey("a1!")).toThrow();
|
||||
expect(() => validateOrderKey("a1_")).toThrow();
|
||||
expect(() => validateOrderKey("zd0032")).toThrow();
|
||||
});
|
||||
|
||||
it("should accept valid base62 order keys", () => {
|
||||
expect(() => validateOrderKey("Zz")).not.toThrow();
|
||||
expect(() => validateOrderKey("a0")).not.toThrow();
|
||||
expect(() => validateOrderKey("a1")).not.toThrow();
|
||||
expect(() => validateOrderKey("a1V")).not.toThrow();
|
||||
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync invalid indices with array order", () => {
|
||||
describe("should NOT sync empty array", () => {
|
||||
@@ -104,6 +124,46 @@ describe("sync invalid indices with array order", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when fractional index is malformed", () => {
|
||||
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
|
||||
// but the string is far too short, so validateOrderKey throws for it
|
||||
testInvalidIndicesSync({
|
||||
elements: [{ id: "A", index: "zd0032" }],
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "zd0032" },
|
||||
{ id: "C", index: "a3" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [{ id: "A", index: "a!" }],
|
||||
expect: {
|
||||
unchangedElements: [],
|
||||
},
|
||||
});
|
||||
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a!" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when fractional indices are duplicated", () => {
|
||||
testInvalidIndicesSync({
|
||||
elements: [
|
||||
|
||||
@@ -2,15 +2,24 @@ import {
|
||||
convertToExcalidrawElements,
|
||||
Excalidraw,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
|
||||
import {
|
||||
getCloneByOrigId,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
|
||||
|
||||
import { elementOverlapsWithFrame } from "../src/frame";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
} from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@@ -125,6 +134,250 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should treat an element fully containing a frame as overlapping the frame", () => {
|
||||
const containingRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: -50,
|
||||
y: -50,
|
||||
width: 250,
|
||||
height: 250,
|
||||
});
|
||||
|
||||
API.setElements([containingRect, frame]);
|
||||
|
||||
expect(
|
||||
elementOverlapsWithFrame(
|
||||
containingRect,
|
||||
frame as ExcalidrawFrameLikeElement,
|
||||
arrayToMap(h.elements),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not add a newly created element to a frame behind a non-frame element", () => {
|
||||
const cover = API.createElement({
|
||||
id: "cover",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
});
|
||||
|
||||
API.setElements([frame, cover]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(40, 40);
|
||||
mouse.upAt(40, 40);
|
||||
|
||||
const createdElement = h.elements.find(
|
||||
(element) => element.id !== frame.id && element.id !== cover.id,
|
||||
);
|
||||
|
||||
expect(createdElement?.frameId).toBe(null);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frame.id,
|
||||
cover.id,
|
||||
createdElement?.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add a newly created element to a frame over a non-frame element", () => {
|
||||
const cover = API.createElement({
|
||||
id: "cover",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
});
|
||||
|
||||
API.setElements([cover, frame]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(40, 40);
|
||||
mouse.upAt(40, 40);
|
||||
|
||||
const createdElement = h.elements.find(
|
||||
(element) => element.id !== frame.id && element.id !== cover.id,
|
||||
);
|
||||
|
||||
expect(createdElement?.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should highlight the target frame while creating a new element", () => {
|
||||
API.setElements([frame]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(40, 40);
|
||||
|
||||
expect(h.state.frameToHighlight?.id).toBe(frame.id);
|
||||
|
||||
mouse.upAt(40, 40);
|
||||
|
||||
expect(h.state.frameToHighlight).toBe(null);
|
||||
});
|
||||
|
||||
it("should highlight the target frame while hovering with a creation tool", () => {
|
||||
API.setElements([frame]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.moveTo(20, 20);
|
||||
|
||||
expect(h.state.frameToHighlight?.id).toBe(frame.id);
|
||||
|
||||
mouse.moveTo(200, 200);
|
||||
|
||||
expect(h.state.frameToHighlight).toBe(null);
|
||||
});
|
||||
|
||||
it("should not add grid-snapped text outside the frame to the clicked frame", async () => {
|
||||
const offsetFrame = API.createElement({
|
||||
id: "offsetFrame",
|
||||
type: "frame",
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 150,
|
||||
height: 150,
|
||||
});
|
||||
|
||||
API.setElements([offsetFrame]);
|
||||
API.setAppState({
|
||||
gridModeEnabled: true,
|
||||
});
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(12, 0);
|
||||
|
||||
await getTextEditor();
|
||||
|
||||
const createdText = h.elements.find(
|
||||
(element) => element.id !== offsetFrame.id,
|
||||
);
|
||||
|
||||
expect(createdText?.x).toBe(0);
|
||||
expect(createdText?.y).toBe(0);
|
||||
expect(createdText?.frameId).toBe(null);
|
||||
});
|
||||
|
||||
it("should add a newly created element to a frame behind another frame", () => {
|
||||
const lockedFrame = API.createElement({
|
||||
id: "lockedFrame",
|
||||
type: "frame",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
locked: true,
|
||||
});
|
||||
|
||||
API.setElements([frame, lockedFrame]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(40, 40);
|
||||
mouse.upAt(40, 40);
|
||||
|
||||
const createdElement = h.elements.find(
|
||||
(element) => element.id !== frame.id && element.id !== lockedFrame.id,
|
||||
);
|
||||
|
||||
expect(createdElement?.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should insert a newly created frame child just below its frame", () => {
|
||||
const frameChildUnderCursor = API.createElement({
|
||||
id: "frameChildUnderCursor",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
frameId: frame.id,
|
||||
});
|
||||
const otherFrameChild = API.createElement({
|
||||
id: "otherFrameChild",
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frameChildUnderCursor, otherFrameChild, frame]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(40, 40);
|
||||
mouse.upAt(40, 40);
|
||||
|
||||
const createdElement = h.elements.find(
|
||||
(element) =>
|
||||
element.id !== frame.id &&
|
||||
element.id !== frameChildUnderCursor.id &&
|
||||
element.id !== otherFrameChild.id,
|
||||
);
|
||||
|
||||
expect(createdElement?.frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frameChildUnderCursor.id,
|
||||
otherFrameChild.id,
|
||||
createdElement?.id,
|
||||
frame.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should insert a newly created frame child above the highest frame child", () => {
|
||||
const frameChildUnderCursor = API.createElement({
|
||||
id: "frameChildUnderCursor",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
frameId: frame.id,
|
||||
});
|
||||
const otherFrameChild = API.createElement({
|
||||
id: "otherFrameChild",
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, frameChildUnderCursor, otherFrameChild]);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.downAt(20, 20);
|
||||
mouse.moveTo(40, 40);
|
||||
mouse.upAt(40, 40);
|
||||
|
||||
const createdElement = h.elements.find(
|
||||
(element) =>
|
||||
element.id !== frame.id &&
|
||||
element.id !== frameChildUnderCursor.id &&
|
||||
element.id !== otherFrameChild.id,
|
||||
);
|
||||
|
||||
expect(createdElement?.frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frame.id,
|
||||
frameChildUnderCursor.id,
|
||||
otherFrameChild.id,
|
||||
createdElement?.id,
|
||||
]);
|
||||
});
|
||||
|
||||
const commonTestCases = async (
|
||||
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
|
||||
) => {
|
||||
@@ -415,6 +668,345 @@ describe("adding elements to frames", () => {
|
||||
describe("dragging elements into the frame", async () => {
|
||||
await commonTestCases(dragElementIntoFrame);
|
||||
|
||||
it("should add a dragged element fully containing the frame", () => {
|
||||
const containingRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 220,
|
||||
y: 20,
|
||||
width: 300,
|
||||
height: 300,
|
||||
});
|
||||
|
||||
API.setElements([frame, containingRect]);
|
||||
|
||||
dragElementIntoFrame(frame, containingRect);
|
||||
|
||||
expect(API.getElement(containingRect).frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should drag an element into a frame", () => {
|
||||
API.setElements([rect2, frame]);
|
||||
|
||||
dragElementIntoFrame(frame, rect2);
|
||||
|
||||
expect(rect2.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should layer a dragged element above the highest frame child", () => {
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, frameChild, rect2]);
|
||||
|
||||
dragElementIntoFrame(frame, rect2);
|
||||
|
||||
expect(rect2.frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frame.id,
|
||||
frameChild.id,
|
||||
rect2.id,
|
||||
]);
|
||||
expect(rect2.index! > frameChild.index!).toBe(true);
|
||||
expect(rect2.index! > frame.index!).toBe(true);
|
||||
});
|
||||
|
||||
it("should preview a dragged element above the highest frame child before pointerup", () => {
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([rect2, frame, frameChild]);
|
||||
API.setSelectedElements([rect2]);
|
||||
API.updateElement(rect2, {
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
|
||||
const getRenderableElementIds = (
|
||||
selectedElementsAreBeingDragged: boolean,
|
||||
) => {
|
||||
return h.app.renderer
|
||||
.getRenderableElements({
|
||||
zoom: h.state.zoom,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
editingTextElement: h.state.editingTextElement,
|
||||
newElement: h.state.newElement,
|
||||
selectedElements: getSelectedElements(h.elements, h.state),
|
||||
selectedElementsAreBeingDragged,
|
||||
frameToHighlight: frame as ExcalidrawFrameLikeElement,
|
||||
})
|
||||
.visibleElements.map((element) => element.id);
|
||||
};
|
||||
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
rect2.id,
|
||||
frame.id,
|
||||
frameChild.id,
|
||||
]);
|
||||
expect(getRenderableElementIds(false)).toEqual([
|
||||
rect2.id,
|
||||
frame.id,
|
||||
frameChild.id,
|
||||
]);
|
||||
expect(getRenderableElementIds(true)).toEqual([
|
||||
frame.id,
|
||||
frameChild.id,
|
||||
rect2.id,
|
||||
]);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
rect2.id,
|
||||
frame.id,
|
||||
frameChild.id,
|
||||
]);
|
||||
expect(rect2.frameId).toBe(null);
|
||||
});
|
||||
|
||||
it("should not preview reorder dragged elements already in the highlighted frame", () => {
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const otherFrameChild = API.createElement({
|
||||
id: "otherFrameChild",
|
||||
type: "rectangle",
|
||||
x: 40,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frameChild, frame, otherFrameChild]);
|
||||
API.setSelectedElements([frameChild]);
|
||||
|
||||
const renderableElementIds = h.app.renderer
|
||||
.getRenderableElements({
|
||||
zoom: h.state.zoom,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
editingTextElement: h.state.editingTextElement,
|
||||
newElement: h.state.newElement,
|
||||
selectedElements: getSelectedElements(h.elements, h.state),
|
||||
selectedElementsAreBeingDragged: true,
|
||||
frameToHighlight: frame as ExcalidrawFrameLikeElement,
|
||||
})
|
||||
.visibleElements.map((element) => element.id);
|
||||
|
||||
expect(renderableElementIds).toEqual([
|
||||
frameChild.id,
|
||||
frame.id,
|
||||
otherFrameChild.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should put a dragged mixed selection above the highest frame child", () => {
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
boundElements: [{ id: "boundText", type: "text" }],
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
x: 50,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
containerId: frameChild.id,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const otherFrameChild = API.createElement({
|
||||
id: "otherFrameChild",
|
||||
type: "rectangle",
|
||||
x: 80,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const nonFrameElement = API.createElement({
|
||||
id: "nonFrameElement",
|
||||
type: "rectangle",
|
||||
x: 155,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
});
|
||||
|
||||
API.setElements([
|
||||
frame,
|
||||
frameChild,
|
||||
boundText,
|
||||
otherFrameChild,
|
||||
nonFrameElement,
|
||||
]);
|
||||
API.setSelectedElements([frameChild, nonFrameElement]);
|
||||
|
||||
mouse.downAt(
|
||||
nonFrameElement.x + nonFrameElement.width / 2,
|
||||
nonFrameElement.y + nonFrameElement.height / 2,
|
||||
);
|
||||
mouse.moveTo(frame.x + frame.width - 5, nonFrameElement.y + 10);
|
||||
mouse.up();
|
||||
|
||||
expect(frameChild.frameId).toBe(frame.id);
|
||||
expect(boundText.frameId).toBe(frame.id);
|
||||
expect(nonFrameElement.frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frame.id,
|
||||
otherFrameChild.id,
|
||||
frameChild.id,
|
||||
boundText.id,
|
||||
nonFrameElement.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not reorder dragged elements already in the highlighted frame", () => {
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const otherFrameChild = API.createElement({
|
||||
id: "otherFrameChild",
|
||||
type: "rectangle",
|
||||
x: 80,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, frameChild, otherFrameChild]);
|
||||
API.setSelectedElements([frameChild]);
|
||||
|
||||
mouse.downAt(
|
||||
frameChild.x + frameChild.width / 2,
|
||||
frameChild.y + frameChild.height / 2,
|
||||
);
|
||||
mouse.moveTo(frameChild.x + frameChild.width / 2 + 5, frameChild.y + 10);
|
||||
mouse.up();
|
||||
|
||||
expect(frameChild.frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frame.id,
|
||||
frameChild.id,
|
||||
otherFrameChild.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not drag an element into a frame behind a non-frame element", () => {
|
||||
const cover = API.createElement({
|
||||
id: "cover",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
});
|
||||
API.setElements([frame, cover, rect2]);
|
||||
|
||||
mouse.clickAt(rect2.x, rect2.y);
|
||||
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
|
||||
mouse.moveTo(20, 20);
|
||||
mouse.upAt(20, 20);
|
||||
|
||||
expect(rect2.frameId).toBe(null);
|
||||
});
|
||||
|
||||
it("should drag an element into a frame over a non-frame element", () => {
|
||||
const cover = API.createElement({
|
||||
id: "cover",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
});
|
||||
API.setElements([cover, rect2, frame]);
|
||||
|
||||
mouse.clickAt(rect2.x, rect2.y);
|
||||
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
|
||||
mouse.moveTo(20, 20);
|
||||
mouse.upAt(20, 20);
|
||||
|
||||
expect(rect2.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should keep dragging a frame child over a non-frame element above its frame", () => {
|
||||
const cover = API.createElement({
|
||||
id: "cover",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "#ffc9c9",
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frameChild, frame, cover]);
|
||||
API.setSelectedElements([frameChild]);
|
||||
|
||||
mouse.downAt(
|
||||
frameChild.x + frameChild.width / 2,
|
||||
frameChild.y + frameChild.height / 2,
|
||||
);
|
||||
mouse.moveTo(20, 20);
|
||||
|
||||
expect(h.state.frameToHighlight?.id).toBe(frame.id);
|
||||
|
||||
mouse.upAt(20, 20);
|
||||
|
||||
expect(frameChild.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
|
||||
API.setElements([frame, rect2]);
|
||||
|
||||
|
||||
@@ -155,24 +155,6 @@ describe("Test Linear Elements", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const dragMove = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
||||
fireEvent.pointerDown(interactiveCanvas, {
|
||||
clientX: startPoint[0],
|
||||
clientY: startPoint[1],
|
||||
});
|
||||
fireEvent.pointerMove(interactiveCanvas, {
|
||||
clientX: endPoint[0],
|
||||
clientY: endPoint[1],
|
||||
});
|
||||
};
|
||||
|
||||
const dragEnd = (endPoint: GlobalPoint) => {
|
||||
fireEvent.pointerUp(interactiveCanvas, {
|
||||
clientX: endPoint[0],
|
||||
clientY: endPoint[1],
|
||||
});
|
||||
};
|
||||
|
||||
const deletePoint = (point: GlobalPoint) => {
|
||||
fireEvent.pointerDown(interactiveCanvas, {
|
||||
clientX: point[0],
|
||||
@@ -276,73 +258,6 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("shows snap lines and snaps the endpoint when creating a line", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 40,
|
||||
height: 40,
|
||||
});
|
||||
API.setElements([rect]);
|
||||
API.setAppState({ objectsSnapModeEnabled: true });
|
||||
|
||||
UI.clickTool("line");
|
||||
|
||||
const startPoint = pointFrom<GlobalPoint>(20, 20);
|
||||
const pointerNearCorner = pointFrom<GlobalPoint>(95, 95);
|
||||
|
||||
dragMove(startPoint, pointerNearCorner);
|
||||
|
||||
expect(h.state.snapLines.length).toBeGreaterThan(0);
|
||||
|
||||
dragEnd(pointerNearCorner);
|
||||
|
||||
const line = h.elements.find(
|
||||
(element): element is ExcalidrawLinearElement => element.type === "line",
|
||||
);
|
||||
|
||||
expect(line).toBeDefined();
|
||||
|
||||
const endpoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
line!,
|
||||
line!.points[line!.points.length - 1],
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
expect(endpoint).toEqual(pointFrom<GlobalPoint>(100, 100));
|
||||
});
|
||||
|
||||
it("prefers binding over external snaps when creating an arrow endpoint", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 40,
|
||||
height: 40,
|
||||
});
|
||||
API.setElements([rect]);
|
||||
API.setAppState({ objectsSnapModeEnabled: true });
|
||||
|
||||
UI.clickTool("arrow");
|
||||
|
||||
const startPoint = pointFrom<GlobalPoint>(20, 20);
|
||||
const pointerNearBindable = pointFrom<GlobalPoint>(96, 118);
|
||||
|
||||
dragMove(startPoint, pointerNearBindable);
|
||||
|
||||
expect(h.state.suggestedBinding?.element.id).toBe(rect.id);
|
||||
expect(h.state.snapLines).toEqual([]);
|
||||
|
||||
dragEnd(pointerNearBindable);
|
||||
|
||||
const arrow = h.elements.find(
|
||||
(element): element is ExcalidrawLinearElement => element.type === "arrow",
|
||||
);
|
||||
|
||||
expect(arrow?.endBinding?.elementId).toBe(rect.id);
|
||||
});
|
||||
|
||||
it("should enter line editor via enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
@@ -486,77 +401,6 @@ describe("Test Linear Elements", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("shows snap lines when dragging a point to another line point axis", () => {
|
||||
const line = API.createElement({
|
||||
type: "line",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
roughness: 0,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(50, 50),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
],
|
||||
});
|
||||
|
||||
API.setElements([line]);
|
||||
API.setAppState({ objectsSnapModeEnabled: true });
|
||||
enterLineEditingMode(line);
|
||||
|
||||
const middlePoint = pointFrom<GlobalPoint>(70, 70);
|
||||
const pointerNearEndPointX = pointFrom<GlobalPoint>(117, 65);
|
||||
|
||||
dragMove(middlePoint, pointerNearEndPointX);
|
||||
|
||||
expect(h.state.snapLines.length).toBeGreaterThan(0);
|
||||
|
||||
dragEnd(pointerNearEndPointX);
|
||||
|
||||
expect(API.getElement(line).points[1]).toEqual(
|
||||
pointFrom<LocalPoint>(100, 45),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers binding over external snaps when dragging an existing arrow endpoint", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 40,
|
||||
height: 40,
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 40,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(40, 0)],
|
||||
});
|
||||
|
||||
API.setElements([rect, arrow]);
|
||||
API.setAppState({ objectsSnapModeEnabled: true });
|
||||
enterLineEditingMode(arrow);
|
||||
|
||||
const endPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
arrow,
|
||||
arrow.points[arrow.points.length - 1],
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const pointerNearBindable = pointFrom<GlobalPoint>(96, 118);
|
||||
|
||||
dragMove(endPoint, pointerNearBindable);
|
||||
|
||||
expect(h.state.suggestedBinding?.element.id).toBe(rect.id);
|
||||
expect(h.state.snapLines).toEqual([]);
|
||||
|
||||
dragEnd(pointerNearBindable);
|
||||
|
||||
expect(API.getElement(arrow).endBinding?.elementId).toBe(rect.id);
|
||||
});
|
||||
|
||||
it("should update the midpoints when element roundness changed", async () => {
|
||||
createThreePointerLinearElement("line");
|
||||
|
||||
|
||||
@@ -326,19 +326,59 @@ describe("normalizeElementsOrder", () => {
|
||||
]),
|
||||
[
|
||||
"BA_rect1",
|
||||
"CBA_rect3",
|
||||
"CBA_rect7",
|
||||
"BA_rect5",
|
||||
"BA_rect6",
|
||||
"A_rect2",
|
||||
"A_rect5",
|
||||
"CBA_rect3",
|
||||
"CBA_rect7",
|
||||
"rect4",
|
||||
"X_rect8",
|
||||
"X_rect11",
|
||||
"YX_rect10",
|
||||
"X_rect11",
|
||||
"rect9",
|
||||
],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "CBA_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["C", "B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect3",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "CBA_rect2", "A_rect3"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "abcT_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["ab", "c", "T"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "abcT_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["a", "bc", "T"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "abcT_rect3",
|
||||
type: "rectangle",
|
||||
groupIds: ["ab", "c", "T"],
|
||||
}),
|
||||
]),
|
||||
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
|
||||
);
|
||||
});
|
||||
|
||||
// TODO
|
||||
|
||||
@@ -101,6 +101,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -118,6 +119,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
||||
@@ -329,8 +329,8 @@ export const actionFinalize = register<FormData>({
|
||||
selectionElement: null,
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds:
|
||||
element &&
|
||||
!appState.activeTool.locked &&
|
||||
|
||||
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
|
||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||
selectedElements,
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -277,7 +277,6 @@ export const actionUngroup = register({
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
isWindows,
|
||||
KEYS,
|
||||
matchKey,
|
||||
arrayToMap,
|
||||
@@ -114,7 +113,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
PanelComponent: ({ appState, updateData, data, app }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
|
||||
@@ -1897,6 +1897,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
@@ -1911,6 +1912,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
@@ -1943,6 +1945,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
appState.bindMode === "inside" ? "inside" : "orbit",
|
||||
"start",
|
||||
app.scene,
|
||||
appState.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1957,6 +1960,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
appState.bindMode === "inside" ? "inside" : "orbit",
|
||||
"end",
|
||||
app.scene,
|
||||
appState.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import {
|
||||
SVG_NS,
|
||||
getSvgPathFromStroke,
|
||||
@@ -8,7 +7,8 @@ import {
|
||||
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import { AnimationController } from "./renderer/animation";
|
||||
|
||||
import type App from "./components/App";
|
||||
import type { AppState } from "./types";
|
||||
|
||||
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
|
||||
private container?: SVGSVGElement;
|
||||
private trailElement: SVGPathElement;
|
||||
private trailAnimation?: SVGAnimateElement;
|
||||
private key: string;
|
||||
|
||||
private static counter = 0;
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
protected app: App,
|
||||
private options: Partial<LaserPointerOptions> &
|
||||
Partial<AnimatedTrailOptions>,
|
||||
) {
|
||||
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||
|
||||
this.key = `animated-trail-${AnimatedTrail.counter++}`;
|
||||
this.trailElement = document.createElementNS(SVG_NS, "path");
|
||||
if (this.options.animateTrail) {
|
||||
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
|
||||
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.pastTrails = [];
|
||||
this.currentTrail = undefined;
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
}
|
||||
|
||||
start(container?: SVGSVGElement) {
|
||||
if (container) {
|
||||
this.container = container;
|
||||
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
|
||||
this.container.appendChild(this.trailElement);
|
||||
}
|
||||
|
||||
this.animationFrameHandler.start(this);
|
||||
if (!AnimationController.running(this.key)) {
|
||||
AnimationController.start(this.key, () => {
|
||||
const needsNext = this.onFrame();
|
||||
if (needsNext) {
|
||||
return { keep: true };
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.animationFrameHandler.stop(this);
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
AnimationController.cancel(this.key);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
|
||||
|
||||
if (this.currentTrail) {
|
||||
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
|
||||
|
||||
paths.push(currentPath);
|
||||
}
|
||||
|
||||
this.pastTrails = this.pastTrails.filter((trail) => {
|
||||
return trail.getStrokeOutline().length !== 0;
|
||||
});
|
||||
this.pastTrails = this.pastTrails.filter(
|
||||
(t) =>
|
||||
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
|
||||
.length !== 0,
|
||||
);
|
||||
|
||||
if (paths.length === 0) {
|
||||
this.stop();
|
||||
// Clean up the SVG path if there are no trails to render
|
||||
this.trailElement.setAttribute("d", "");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const svgPaths = paths.join(" ").trim();
|
||||
|
||||
this.trailElement.setAttribute("d", svgPaths);
|
||||
|
||||
if (this.trailAnimation) {
|
||||
this.trailElement.setAttribute(
|
||||
"fill",
|
||||
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
|
||||
(this.options.fill ?? (() => "black"))(this),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private drawTrail(trail: LaserPointer, state: AppState): string {
|
||||
@@ -1,79 +0,0 @@
|
||||
export type AnimationCallback = (timestamp: number) => void | boolean;
|
||||
|
||||
export type AnimationTarget = {
|
||||
callback: AnimationCallback;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export class AnimationFrameHandler {
|
||||
private targets = new WeakMap<object, AnimationTarget>();
|
||||
private rafIds = new WeakMap<object, number>();
|
||||
|
||||
register(key: object, callback: AnimationCallback) {
|
||||
this.targets.set(key, { callback, stopped: true });
|
||||
}
|
||||
|
||||
start(key: object) {
|
||||
const target = this.targets.get(key);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rafIds.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.targets.set(key, { ...target, stopped: false });
|
||||
this.scheduleFrame(key);
|
||||
}
|
||||
|
||||
stop(key: object) {
|
||||
const target = this.targets.get(key);
|
||||
if (target && !target.stopped) {
|
||||
this.targets.set(key, { ...target, stopped: true });
|
||||
}
|
||||
|
||||
this.cancelFrame(key);
|
||||
}
|
||||
|
||||
private constructFrame(key: object): FrameRequestCallback {
|
||||
return (timestamp: number) => {
|
||||
const target = this.targets.get(key);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAbort = this.onFrame(target, timestamp);
|
||||
|
||||
if (!target.stopped && !shouldAbort) {
|
||||
this.scheduleFrame(key);
|
||||
} else {
|
||||
this.cancelFrame(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleFrame(key: object) {
|
||||
const rafId = requestAnimationFrame(this.constructFrame(key));
|
||||
|
||||
this.rafIds.set(key, rafId);
|
||||
}
|
||||
|
||||
private cancelFrame(key: object) {
|
||||
if (this.rafIds.has(key)) {
|
||||
const rafId = this.rafIds.get(key)!;
|
||||
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
this.rafIds.delete(key);
|
||||
}
|
||||
|
||||
private onFrame(target: AnimationTarget, timestamp: number): boolean {
|
||||
const shouldAbort = target.callback(timestamp);
|
||||
|
||||
return shouldAbort ?? false;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,6 @@ export const getDefaultAppState = (): Omit<
|
||||
open: false,
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
startBoundElement: null,
|
||||
suggestedBinding: null,
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
@@ -231,7 +230,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBinding: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
|
||||
import {
|
||||
isFlowchartNodeElement,
|
||||
isImageElement,
|
||||
isGridModeEnabled,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextBindableContainer,
|
||||
@@ -17,6 +16,7 @@ import type { EditorInterface } from "@excalidraw/common";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
import { isEraserActive } from "../appState";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
|
||||
|
||||
@@ -650,8 +650,7 @@ const LayerUI = ({
|
||||
};
|
||||
|
||||
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
|
||||
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
|
||||
appState;
|
||||
const { cursorButton, scrollX, scrollY, ...ret } = appState;
|
||||
return ret;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function LibraryMenuItems({
|
||||
type: "everything",
|
||||
elements: item.elements,
|
||||
randomizeSeed: true,
|
||||
preserveFrameChildrenOrder: true,
|
||||
}).duplicatedElements,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
|
||||
import "./SVGLayer.scss";
|
||||
|
||||
import type { Trail } from "../animated-trail";
|
||||
import type { Trail } from "../animatedTrail";
|
||||
|
||||
type SVGLayerProps = {
|
||||
trails: Trail[];
|
||||
|
||||
@@ -206,7 +206,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
@@ -302,7 +301,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
@@ -261,7 +261,6 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
@@ -416,7 +415,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
|
||||
app.scene.getElementsIncludingDeleted(),
|
||||
nextElementsInFrame,
|
||||
latestElement,
|
||||
app,
|
||||
);
|
||||
|
||||
app.scene.replaceAllElements(updatedElements);
|
||||
|
||||
@@ -12,11 +12,10 @@ import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
|
||||
|
||||
import { elementsAreInSameGroup } from "@excalidraw/element";
|
||||
|
||||
import { isGridModeEnabled } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { isGridModeEnabled } from "../../snapping";
|
||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||
import { Island } from "../Island";
|
||||
import { CloseIcon } from "../icons";
|
||||
|
||||
@@ -39,7 +39,7 @@ type InteractiveCanvasProps = {
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
sceneNonce: number | undefined;
|
||||
canvasNonce: string;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
@@ -279,10 +279,10 @@ const areEqual = (
|
||||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||
if (
|
||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||
prevProps.canvasNonce !== nextProps.canvasNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on elementsMap because they may have renewed
|
||||
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||
// even if canvasNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
interface NewElementCanvasProps {
|
||||
appState: AppState;
|
||||
newElement: NonNullable<AppState["newElement"]>;
|
||||
elementsMap: RenderableElementsMap;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
scale: number;
|
||||
@@ -31,7 +32,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
|
||||
{
|
||||
canvas: canvasRef.current,
|
||||
scale: props.scale,
|
||||
newElement: props.appState.newElement,
|
||||
newElement: props.newElement,
|
||||
elementsMap: props.elementsMap,
|
||||
allElementsMap: props.allElementsMap,
|
||||
rc: props.rc,
|
||||
|
||||
@@ -23,7 +23,7 @@ type StaticCanvasProps = {
|
||||
elementsMap: RenderableElementsMap;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
sceneNonce: number | undefined;
|
||||
canvasNonce: string;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
@@ -110,10 +110,10 @@ const areEqual = (
|
||||
nextProps: StaticCanvasProps,
|
||||
) => {
|
||||
if (
|
||||
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||
prevProps.canvasNonce !== nextProps.canvasNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on elementsMap because they may have renewed
|
||||
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||
// even if canvasNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements
|
||||
|
||||
@@ -119,15 +119,12 @@ export const SHAPES = [
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.state.preferredSelectionTool.type === "lasso"
|
||||
? ([
|
||||
SHAPES[0],
|
||||
{
|
||||
...SHAPES[1],
|
||||
value: "lasso",
|
||||
icon: SelectionIcon,
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
toolbar: true,
|
||||
},
|
||||
...SHAPES.slice(1),
|
||||
...SHAPES.slice(2),
|
||||
] as const)
|
||||
: SHAPES;
|
||||
};
|
||||
|
||||
@@ -814,6 +814,14 @@ body.excalidraw-cursor-resize * {
|
||||
.excalidraw__embeddable__outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.excalidraw__embeddable__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: top left;
|
||||
|
||||
&,
|
||||
& > * {
|
||||
border-radius: var(--embeddable-radius);
|
||||
}
|
||||
|
||||
@@ -311,6 +311,48 @@ export const dataURLToString = (dataURL: DataURL) => {
|
||||
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
|
||||
};
|
||||
|
||||
const getImageFileDimensions = async (file: File) => {
|
||||
const browserURL = typeof window !== "undefined" ? window.URL : undefined;
|
||||
let objectURL: string | null = null;
|
||||
let imageSource: string;
|
||||
|
||||
try {
|
||||
imageSource = browserURL?.createObjectURL
|
||||
? (objectURL = browserURL.createObjectURL(file))
|
||||
: await getDataURL(file);
|
||||
} catch {
|
||||
objectURL = null;
|
||||
imageSource = await getDataURL(file);
|
||||
}
|
||||
|
||||
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
const cleanup = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
|
||||
if (objectURL && browserURL?.revokeObjectURL) {
|
||||
browserURL.revokeObjectURL(objectURL);
|
||||
}
|
||||
};
|
||||
|
||||
image.onload = () => {
|
||||
cleanup();
|
||||
resolve({
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
});
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
image.src = imageSource;
|
||||
});
|
||||
};
|
||||
|
||||
export const resizeImageFile = async (
|
||||
file: File,
|
||||
opts: {
|
||||
@@ -324,6 +366,20 @@ export const resizeImageFile = async (
|
||||
return file;
|
||||
}
|
||||
|
||||
if (!isSupportedImageFile(file)) {
|
||||
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||
}
|
||||
|
||||
if (!opts.outputType || opts.outputType === file.type) {
|
||||
const dimensions = await getImageFileDimensions(file);
|
||||
|
||||
if (
|
||||
Math.max(dimensions.width, dimensions.height) <= opts.maxWidthOrHeight
|
||||
) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
const [pica, imageBlobReduce] = await Promise.all([
|
||||
import("pica").then((res) => res.default),
|
||||
// a wrapper for pica for better API
|
||||
@@ -347,10 +403,6 @@ export const resizeImageFile = async (
|
||||
};
|
||||
}
|
||||
|
||||
if (!isSupportedImageFile(file)) {
|
||||
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||
}
|
||||
|
||||
return new File(
|
||||
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
|
||||
file.name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
|
||||
import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
type CombineBrandsIfNeeded,
|
||||
@@ -98,6 +98,67 @@ type RestoredAppState = Omit<
|
||||
|
||||
const MAX_ARROW_PX = 75_000;
|
||||
|
||||
const restoreLinearElementPoints = (
|
||||
points: unknown,
|
||||
width: unknown,
|
||||
height: unknown,
|
||||
): LocalPoint[] => {
|
||||
const restoredPoints = Array.isArray(points)
|
||||
? points.reduce<LocalPoint[]>((acc, point) => {
|
||||
if (isValidPoint(point)) {
|
||||
acc.push(pointFrom<LocalPoint>(point[0], point[1]));
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
: [];
|
||||
|
||||
return restoredPoints.length < 2
|
||||
? [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(
|
||||
isFiniteNumber(width) ? width : 0,
|
||||
isFiniteNumber(height) ? height : 0,
|
||||
),
|
||||
]
|
||||
: restoredPoints;
|
||||
};
|
||||
|
||||
const restoreFreedrawPoints = (
|
||||
points: unknown,
|
||||
pressures: unknown,
|
||||
): {
|
||||
points: LocalPoint[];
|
||||
pressures: number[];
|
||||
} => {
|
||||
if (!Array.isArray(points)) {
|
||||
return {
|
||||
points: [],
|
||||
pressures: [],
|
||||
};
|
||||
}
|
||||
|
||||
const pressureValues: readonly unknown[] = Array.isArray(pressures)
|
||||
? pressures
|
||||
: [];
|
||||
const restoredPoints: LocalPoint[] = [];
|
||||
const restoredPressures: number[] = [];
|
||||
|
||||
points.forEach((point, index) => {
|
||||
if (isValidPoint(point)) {
|
||||
restoredPoints.push(pointFrom<LocalPoint>(point[0], point[1]));
|
||||
if (index in pressureValues) {
|
||||
const pressure = pressureValues[index];
|
||||
restoredPressures.push(isFiniteNumber(pressure) ? pressure : 0.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
points: restoredPoints,
|
||||
pressures: restoredPressures,
|
||||
};
|
||||
};
|
||||
|
||||
export const AllowedExcalidrawActiveTools: Record<
|
||||
AppState["activeTool"]["type"],
|
||||
boolean
|
||||
@@ -414,10 +475,15 @@ export const restoreElement = (
|
||||
|
||||
return element;
|
||||
case "freedraw": {
|
||||
const { points, pressures } = restoreFreedrawPoints(
|
||||
element.points,
|
||||
element.pressures,
|
||||
);
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
points: element.points,
|
||||
points,
|
||||
simulatePressure: element.simulatePressure,
|
||||
pressures: element.pressures,
|
||||
pressures,
|
||||
});
|
||||
}
|
||||
case "image":
|
||||
@@ -435,14 +501,20 @@ export const restoreElement = (
|
||||
const endArrowhead = normalizeArrowhead(element.endArrowhead);
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
|
||||
: element.points;
|
||||
let points = restoreLinearElementPoints(
|
||||
element.points,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
({ points, x, y } =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords({
|
||||
...element,
|
||||
points,
|
||||
x: x ?? 0,
|
||||
y: y ?? 0,
|
||||
} as ExcalidrawLinearElement));
|
||||
}
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
@@ -456,7 +528,7 @@ export const restoreElement = (
|
||||
y,
|
||||
...(isLineElement(element)
|
||||
? {
|
||||
polygon: isValidPolygon(element.points)
|
||||
polygon: isValidPolygon(points)
|
||||
? element.polygon ?? false
|
||||
: false,
|
||||
}
|
||||
@@ -471,22 +543,29 @@ export const restoreElement = (
|
||||
: normalizeArrowhead(element.endArrowhead);
|
||||
const x = element.x as number | undefined;
|
||||
const y = element.y as number | undefined;
|
||||
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
|
||||
: element.points;
|
||||
const points = restoreLinearElementPoints(
|
||||
element.points,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
const elementWithRestoredPoints = {
|
||||
...element,
|
||||
points,
|
||||
x: x ?? 0,
|
||||
y: y ?? 0,
|
||||
} as ExcalidrawArrowElement;
|
||||
|
||||
const base = {
|
||||
type: element.type,
|
||||
startBinding: repairBinding(
|
||||
element as ExcalidrawArrowElement,
|
||||
elementWithRestoredPoints,
|
||||
element.startBinding,
|
||||
targetElementsMap,
|
||||
existingElementsMap,
|
||||
"start",
|
||||
),
|
||||
endBinding: repairBinding(
|
||||
element as ExcalidrawArrowElement,
|
||||
elementWithRestoredPoints,
|
||||
element.endBinding,
|
||||
targetElementsMap,
|
||||
existingElementsMap,
|
||||
|
||||
@@ -33,9 +33,7 @@ import type { Bounds } from "@excalidraw/common";
|
||||
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
|
||||
import type { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
import { AnimatedTrail } from "../animatedTrail";
|
||||
|
||||
import type App from "../components/App";
|
||||
|
||||
@@ -43,8 +41,8 @@ export class EraserTrail extends AnimatedTrail {
|
||||
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||
|
||||
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||
super(animationFrameHandler, app, {
|
||||
constructor(app: App) {
|
||||
super(app, {
|
||||
streamline: 0.2,
|
||||
size: 5,
|
||||
keepHead: true,
|
||||
|
||||
@@ -6,7 +6,11 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
|
||||
import {
|
||||
DEFAULT_IMAGE_OPTIONS,
|
||||
DEFAULT_UI_OPTIONS,
|
||||
isShallowEqual,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import App, {
|
||||
ExcalidrawAPIContext,
|
||||
@@ -98,6 +102,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
aiEnabled,
|
||||
showDeprecatedFonts,
|
||||
renderScrollbars,
|
||||
imageOptions,
|
||||
} = props;
|
||||
|
||||
const canvasActions = props.UIOptions?.canvasActions;
|
||||
@@ -128,6 +133,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
UIOptions.canvasActions.toggleTheme = true;
|
||||
}
|
||||
|
||||
const normalizedImageOptions: AppProps["imageOptions"] = {
|
||||
maxFileSizeBytes:
|
||||
imageOptions?.maxFileSizeBytes ?? DEFAULT_IMAGE_OPTIONS.maxFileSizeBytes,
|
||||
maxWidthOrHeight:
|
||||
imageOptions?.maxWidthOrHeight ?? DEFAULT_IMAGE_OPTIONS.maxWidthOrHeight,
|
||||
};
|
||||
|
||||
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
|
||||
|
||||
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
|
||||
@@ -208,6 +220,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
aiEnabled={aiEnabled !== false}
|
||||
showDeprecatedFonts={showDeprecatedFonts}
|
||||
renderScrollbars={renderScrollbars}
|
||||
imageOptions={normalizedImageOptions}
|
||||
>
|
||||
{children}
|
||||
</App>
|
||||
@@ -225,11 +238,13 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
const {
|
||||
initialData: prevInitialData,
|
||||
UIOptions: prevUIOptions = {},
|
||||
imageOptions: prevImageOptions,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
initialData: nextInitialData,
|
||||
UIOptions: nextUIOptions = {},
|
||||
imageOptions: nextImageOptions,
|
||||
...next
|
||||
} = nextProps;
|
||||
|
||||
@@ -273,7 +288,17 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
return prevUIOptions[key] === nextUIOptions[key];
|
||||
});
|
||||
|
||||
return isUIOptionsSame && isShallowEqual(prev, next);
|
||||
const isImageOptionsSame =
|
||||
(prevImageOptions?.maxWidthOrHeight ??
|
||||
DEFAULT_IMAGE_OPTIONS.maxWidthOrHeight) ===
|
||||
(nextImageOptions?.maxWidthOrHeight ??
|
||||
DEFAULT_IMAGE_OPTIONS.maxWidthOrHeight) &&
|
||||
(prevImageOptions?.maxFileSizeBytes ??
|
||||
DEFAULT_IMAGE_OPTIONS.maxFileSizeBytes) ===
|
||||
(nextImageOptions?.maxFileSizeBytes ??
|
||||
DEFAULT_IMAGE_OPTIONS.maxFileSizeBytes);
|
||||
|
||||
return isUIOptionsSame && isImageOptionsSame && isShallowEqual(prev, next);
|
||||
};
|
||||
|
||||
export const Excalidraw = React.memo(ExcalidrawBase, areEqual);
|
||||
|
||||
@@ -2,27 +2,20 @@ import { DEFAULT_LASER_COLOR, easeOut } from "@excalidraw/common";
|
||||
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
|
||||
import { AnimatedTrail } from "./animated-trail";
|
||||
import { AnimatedTrail } from "./animatedTrail";
|
||||
import { getClientColor } from "./clients";
|
||||
|
||||
import type { Trail } from "./animated-trail";
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import type { Trail } from "./animatedTrail";
|
||||
import type App from "./components/App";
|
||||
import type { SocketId } from "./types";
|
||||
|
||||
export class LaserTrails implements Trail {
|
||||
public localTrail: AnimatedTrail;
|
||||
private collabTrails = new Map<SocketId, AnimatedTrail>();
|
||||
|
||||
private container?: SVGSVGElement;
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
private app: App,
|
||||
) {
|
||||
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||
|
||||
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
|
||||
constructor(private app: App) {
|
||||
this.localTrail = new AnimatedTrail(app, {
|
||||
...this.getTrailOptions(),
|
||||
fill: () => DEFAULT_LASER_COLOR,
|
||||
});
|
||||
@@ -63,30 +56,45 @@ export class LaserTrails implements Trail {
|
||||
|
||||
start(container: SVGSVGElement) {
|
||||
this.container = container;
|
||||
|
||||
this.animationFrameHandler.start(this);
|
||||
this.localTrail.start(container);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.animationFrameHandler.stop(this);
|
||||
this.localTrail.stop();
|
||||
this.stopCollabTrails();
|
||||
this.container = undefined;
|
||||
}
|
||||
|
||||
onFrame() {
|
||||
this.updateCollabTrails();
|
||||
private stopCollabTrails(collaborators?: App["state"]["collaborators"]) {
|
||||
for (const [key, trail] of this.collabTrails) {
|
||||
const collaborator = collaborators?.get(key);
|
||||
|
||||
if (!collaborator) {
|
||||
trail.stop();
|
||||
this.collabTrails.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateCollabTrails() {
|
||||
if (!this.container || this.app.state.collaborators.size === 0) {
|
||||
updateCollabTrails(collaborators: App["state"]["collaborators"]) {
|
||||
this.stopCollabTrails(collaborators);
|
||||
|
||||
if (!this.container || collaborators.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, collaborator] of this.app.state.collaborators.entries()) {
|
||||
let trail!: AnimatedTrail;
|
||||
for (const [key, collaborator] of collaborators.entries()) {
|
||||
// Current user has their own trail drawn via localTrail
|
||||
if (collaborator.isCurrentUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.collabTrails.has(key)) {
|
||||
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
|
||||
// IDEA: Use the collaborator pointer coordinates to trace out the
|
||||
// laser pointer trail when 1) the selected collab tool is the laser
|
||||
// pointer and 2) the collab pointer button is in the "down" state.
|
||||
let trail = this.collabTrails.get(key);
|
||||
if (!trail) {
|
||||
trail = new AnimatedTrail(this.app, {
|
||||
...this.getTrailOptions(),
|
||||
fill: () =>
|
||||
collaborator.pointer?.laserColor ||
|
||||
@@ -95,36 +103,33 @@ export class LaserTrails implements Trail {
|
||||
trail.start(this.container);
|
||||
|
||||
this.collabTrails.set(key, trail);
|
||||
} else {
|
||||
trail = this.collabTrails.get(key)!;
|
||||
}
|
||||
|
||||
if (collaborator.pointer && collaborator.pointer.tool === "laser") {
|
||||
if (collaborator.button === "down" && !trail.hasCurrentTrail) {
|
||||
const buttonDown = collaborator.button === "down";
|
||||
const buttonUp = collaborator.button === "up";
|
||||
const hasTrail = trail.hasCurrentTrail;
|
||||
|
||||
// Initialize a new trail
|
||||
if (buttonDown && !hasTrail) {
|
||||
trail.startPath(collaborator.pointer.x, collaborator.pointer.y);
|
||||
}
|
||||
|
||||
if (
|
||||
collaborator.button === "down" &&
|
||||
trail.hasCurrentTrail &&
|
||||
!trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y)
|
||||
) {
|
||||
// Add only original points
|
||||
const lastPointOriginal = !trail.hasLastPoint(
|
||||
collaborator.pointer.x,
|
||||
collaborator.pointer.y,
|
||||
);
|
||||
if (buttonDown && lastPointOriginal) {
|
||||
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
|
||||
}
|
||||
|
||||
if (collaborator.button === "up" && trail.hasCurrentTrail) {
|
||||
// End the trail on button up
|
||||
if (buttonUp && hasTrail) {
|
||||
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
|
||||
trail.endPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of this.collabTrails.keys()) {
|
||||
if (!this.app.state.collaborators.has(key)) {
|
||||
const trail = this.collabTrails.get(key)!;
|
||||
trail.stop();
|
||||
this.collabTrails.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,7 @@ import type {
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { type AnimationFrameHandler } from "../animation-frame-handler";
|
||||
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
import { AnimatedTrail } from "../animatedTrail";
|
||||
|
||||
import { getLassoSelectedElementIds } from "./utils";
|
||||
|
||||
@@ -47,8 +45,8 @@ export class LassoTrail extends AnimatedTrail {
|
||||
private canvasTranslate: CanvasTranslate | null = null;
|
||||
private keepPreviousSelection: boolean = false;
|
||||
|
||||
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||
super(animationFrameHandler, app, {
|
||||
constructor(app: App) {
|
||||
super(app, {
|
||||
animateTrail: true,
|
||||
streamline: 0.4,
|
||||
sizeMapping: (c) => {
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"es6-promise-pool": "2.5.0",
|
||||
"fractional-indexing": "3.2.0",
|
||||
"fuzzy": "0.1.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "2.11.0",
|
||||
|
||||
@@ -23,6 +23,94 @@ const polyfill = () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.findLast) {
|
||||
Object.defineProperty(Array.prototype, "findLast", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
predicate: (value: T, index: number, array: T[]) => unknown,
|
||||
thisArg?: unknown,
|
||||
) {
|
||||
return this
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((value, index) =>
|
||||
predicate.call(thisArg, value, this.length - index - 1, this),
|
||||
);
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.findIndex) {
|
||||
Object.defineProperty(Array.prototype, "findIndex", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
predicate: (value: T, index: number, array: T[]) => unknown,
|
||||
thisArg?: unknown,
|
||||
) {
|
||||
for (let index = 0; index < this.length; index++) {
|
||||
if (predicate.call(thisArg, this[index], index, this)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.findLastIndex) {
|
||||
Object.defineProperty(Array.prototype, "findLastIndex", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
predicate: (value: T, index: number, array: T[]) => unknown,
|
||||
thisArg?: unknown,
|
||||
) {
|
||||
const index = this
|
||||
.slice()
|
||||
.reverse()
|
||||
.findIndex((value, index) =>
|
||||
predicate.call(thisArg, value, this.length - index - 1, this),
|
||||
);
|
||||
|
||||
return index === -1 ? -1 : this.length - index - 1;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.toReversed) {
|
||||
Object.defineProperty(Array.prototype, "toReversed", {
|
||||
value: function <T>(this: T[]) {
|
||||
return this.slice().reverse();
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.prototype.toSorted) {
|
||||
Object.defineProperty(Array.prototype, "toSorted", {
|
||||
value: function <T>(
|
||||
this: T[],
|
||||
compareFn?: (a: T, b: T) => number,
|
||||
) {
|
||||
return this.slice().sort(compareFn);
|
||||
},
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function (...nodes) {
|
||||
this.innerHTML = "";
|
||||
|
||||
@@ -6,7 +6,10 @@ export type Animation<R extends object> = (params: {
|
||||
}) => R | null | undefined;
|
||||
|
||||
export class AnimationController {
|
||||
private static isRunning = false;
|
||||
private static scheduledFrame:
|
||||
| { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
|
||||
| { id: ReturnType<typeof setTimeout>; type: "timeout" }
|
||||
| null = null;
|
||||
private static animations = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -17,6 +20,10 @@ export class AnimationController {
|
||||
>();
|
||||
|
||||
static start<R extends object>(key: string, animation: Animation<R>) {
|
||||
if (AnimationController.animations.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialState = animation({
|
||||
deltaTime: 0,
|
||||
state: undefined,
|
||||
@@ -29,19 +36,54 @@ export class AnimationController {
|
||||
state: initialState,
|
||||
});
|
||||
|
||||
if (!AnimationController.isRunning) {
|
||||
AnimationController.isRunning = true;
|
||||
|
||||
if (isRenderThrottlingEnabled()) {
|
||||
requestAnimationFrame(AnimationController.tick);
|
||||
} else {
|
||||
setTimeout(AnimationController.tick, 0);
|
||||
}
|
||||
}
|
||||
AnimationController.scheduleNextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
private static scheduleNextFrame() {
|
||||
if (AnimationController.scheduledFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRenderThrottlingEnabled()) {
|
||||
AnimationController.scheduledFrame = {
|
||||
id: requestAnimationFrame(AnimationController.tick),
|
||||
type: "raf",
|
||||
};
|
||||
} else {
|
||||
AnimationController.scheduledFrame = {
|
||||
id: setTimeout(AnimationController.tick, 0),
|
||||
type: "timeout",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static cancelScheduledFrame() {
|
||||
if (!AnimationController.scheduledFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AnimationController.scheduledFrame.type === "raf") {
|
||||
cancelAnimationFrame(AnimationController.scheduledFrame.id);
|
||||
} else {
|
||||
clearTimeout(AnimationController.scheduledFrame.id);
|
||||
}
|
||||
|
||||
AnimationController.scheduledFrame = null;
|
||||
}
|
||||
|
||||
private static cancelScheduledFrameIfIdle() {
|
||||
if (AnimationController.animations.size > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AnimationController.cancelScheduledFrame();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static tick() {
|
||||
AnimationController.scheduledFrame = null;
|
||||
|
||||
if (AnimationController.animations.size > 0) {
|
||||
for (const [key, animation] of AnimationController.animations) {
|
||||
const now = performance.now();
|
||||
@@ -56,8 +98,7 @@ export class AnimationController {
|
||||
if (!state) {
|
||||
AnimationController.animations.delete(key);
|
||||
|
||||
if (AnimationController.animations.size === 0) {
|
||||
AnimationController.isRunning = false;
|
||||
if (AnimationController.cancelScheduledFrameIfIdle()) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -66,11 +107,11 @@ export class AnimationController {
|
||||
}
|
||||
}
|
||||
|
||||
if (isRenderThrottlingEnabled()) {
|
||||
requestAnimationFrame(AnimationController.tick);
|
||||
} else {
|
||||
setTimeout(AnimationController.tick, 0);
|
||||
if (AnimationController.cancelScheduledFrameIfIdle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AnimationController.scheduleNextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,5 +121,6 @@ export class AnimationController {
|
||||
|
||||
static cancel(key: string) {
|
||||
AnimationController.animations.delete(key);
|
||||
AnimationController.cancelScheduledFrameIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type Radians,
|
||||
bezierEquation,
|
||||
pointRotateRads,
|
||||
pointDistance,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@@ -24,13 +23,12 @@ import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
elementCenterPoint,
|
||||
getDiamondBaseCorners,
|
||||
getAllMidpoints,
|
||||
FOCUS_POINT_SIZE,
|
||||
getOmitSidesForEditorInterface,
|
||||
getTransformHandles,
|
||||
getTransformHandlesFromCoords,
|
||||
hasBoundingBox,
|
||||
hitElementItself,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isElbowArrow,
|
||||
@@ -38,7 +36,6 @@ import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
maxBindingDistance_simple,
|
||||
isTextElement,
|
||||
LinearElementEditor,
|
||||
getActiveTextElement,
|
||||
@@ -413,145 +410,42 @@ const renderBindingHighlightForBindableElement_simple = (
|
||||
break;
|
||||
}
|
||||
|
||||
// Draw midpoint indicators
|
||||
if (
|
||||
appState.isMidpointSnappingEnabled &&
|
||||
(isFrameLikeElement(suggestedBinding.element) ||
|
||||
isBindableElement(suggestedBinding.element))
|
||||
) {
|
||||
// Draw midpoint indicators
|
||||
const linearElement = appState.selectedLinearElement;
|
||||
const arrow =
|
||||
linearElement?.elementId &&
|
||||
LinearElementEditor.getElement(linearElement?.elementId, elementsMap);
|
||||
const cursorIsInsideBindable =
|
||||
pointerCoords &&
|
||||
hitElementItself({
|
||||
point: pointerCoords,
|
||||
element: suggestedBinding.element,
|
||||
elementsMap,
|
||||
threshold: 0,
|
||||
overrideShouldTestInside: true,
|
||||
});
|
||||
context.save();
|
||||
|
||||
const isElbow =
|
||||
(arrow && isElbowArrow(arrow)) ||
|
||||
(appState.activeTool.type === "arrow" &&
|
||||
appState.currentItemArrowType === "elbow");
|
||||
const midpointRadius = 4 / appState.zoom.value;
|
||||
|
||||
if (!cursorIsInsideBindable || isElbow) {
|
||||
context.save();
|
||||
|
||||
const center = elementCenterPoint(suggestedBinding.element, elementsMap);
|
||||
|
||||
let midpoints: GlobalPoint[];
|
||||
if (suggestedBinding.element.type === "diamond") {
|
||||
const center = elementCenterPoint(
|
||||
suggestedBinding.element,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints = getDiamondBaseCorners(suggestedBinding.element).map(
|
||||
(curve) => {
|
||||
const point = bezierEquation(curve, 0.5);
|
||||
const rotatedPoint = pointRotateRads(
|
||||
point,
|
||||
center,
|
||||
suggestedBinding.element.angle,
|
||||
);
|
||||
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const basePoints = [
|
||||
{
|
||||
x: suggestedBinding.element.width,
|
||||
y: suggestedBinding.element.height / 2,
|
||||
}, // RIGHT
|
||||
{
|
||||
x: suggestedBinding.element.width / 2,
|
||||
y: suggestedBinding.element.height,
|
||||
}, // BOTTOM
|
||||
{ x: 0, y: suggestedBinding.element.height / 2 }, // LEFT
|
||||
{ x: suggestedBinding.element.width / 2, y: 0 }, // TOP
|
||||
];
|
||||
midpoints = basePoints.map((point) => {
|
||||
const globalPoint = pointFrom<GlobalPoint>(
|
||||
point.x + suggestedBinding.element.x,
|
||||
point.y + suggestedBinding.element.y,
|
||||
);
|
||||
const rotatedPoint = pointRotateRads(
|
||||
globalPoint,
|
||||
center,
|
||||
suggestedBinding.element.angle,
|
||||
);
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
});
|
||||
}
|
||||
|
||||
const hoveredMidpoint =
|
||||
pointerCoords &&
|
||||
midpoints.reduce(
|
||||
(
|
||||
closestIdx: {
|
||||
idx: number;
|
||||
distance: number;
|
||||
},
|
||||
point,
|
||||
idx,
|
||||
) => {
|
||||
const distance = pointDistance(point, pointerCoords);
|
||||
if (idx === -1 || distance < closestIdx.distance) {
|
||||
return { idx, distance };
|
||||
}
|
||||
return closestIdx;
|
||||
},
|
||||
{
|
||||
idx: -1,
|
||||
distance: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const midpointRadius = 4 / appState.zoom.value;
|
||||
const highlightThreshold =
|
||||
maxBindingDistance_simple(appState.zoom) +
|
||||
suggestedBinding.element.strokeWidth / 2;
|
||||
|
||||
midpoints.forEach((midpoint, idx) => {
|
||||
const isHighlighted =
|
||||
(!cursorIsInsideBindable || isElbow) &&
|
||||
hoveredMidpoint?.idx === idx &&
|
||||
hoveredMidpoint.distance <= highlightThreshold;
|
||||
|
||||
// also render midpoint if cursor close but not highlighted
|
||||
// (for elbows, always show all points)
|
||||
const isShown =
|
||||
!isHighlighted &&
|
||||
(isElbow ||
|
||||
(idx === hoveredMidpoint?.idx &&
|
||||
hoveredMidpoint.distance <= highlightThreshold * 2));
|
||||
|
||||
if (isHighlighted) {
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? `rgba(3, 93, 161, 1)`
|
||||
: `rgba(106, 189, 252, 1)`;
|
||||
|
||||
context.beginPath();
|
||||
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
} else if (isShown) {
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? `rgba(0, 0, 0, 0.8)`
|
||||
: `rgba(65, 65, 65, 0.5)`;
|
||||
context.beginPath();
|
||||
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
}
|
||||
});
|
||||
|
||||
context.restore();
|
||||
// Render base midpoints
|
||||
const midpoints = getAllMidpoints(suggestedBinding.element, elementsMap);
|
||||
for (const midpoint of midpoints) {
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? `rgba(0, 0, 0, 0.8)`
|
||||
: `rgba(65, 65, 65, 0.5)`;
|
||||
context.beginPath();
|
||||
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
// Render the highlighted midpoint if any
|
||||
const midpoint = appState.suggestedBinding?.midPoint;
|
||||
if (midpoint) {
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? `rgba(3, 93, 161, 1)`
|
||||
: `rgba(106, 189, 252, 1)`;
|
||||
|
||||
context.beginPath();
|
||||
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { THEME } from "@excalidraw/common";
|
||||
|
||||
import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element";
|
||||
|
||||
import type { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
|
||||
const SNAP_COLOR_LIGHT = "#ff6b6b";
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { isElementInViewport } from "@excalidraw/element";
|
||||
import {
|
||||
getCommonFrameId,
|
||||
getFrameChildrenInsertionIndex,
|
||||
isElementInViewport,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { memoize, toBrandedType } from "@excalidraw/common";
|
||||
import { arrayToMap, memoize, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
@@ -16,6 +22,21 @@ import type { RenderableElementsMap } from "./types";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
type GetRenderableElementsOpts = {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElement: AppState["newElement"];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
||||
frameToHighlight: AppState["frameToHighlight"];
|
||||
};
|
||||
|
||||
export class Renderer {
|
||||
private scene: Scene;
|
||||
|
||||
@@ -23,9 +44,121 @@ export class Renderer {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
public getRenderableElements = (() => {
|
||||
const getVisibleCanvasElements = ({
|
||||
elementsMap,
|
||||
private getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
elementsMap: NonDeletedElementsMap;
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
}): readonly NonDeletedExcalidrawElement[] {
|
||||
const visibleElements: NonDeletedExcalidrawElement[] = [];
|
||||
for (const element of elementsMap.values()) {
|
||||
if (
|
||||
isElementInViewport(
|
||||
element,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
},
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
visibleElements.push(element);
|
||||
}
|
||||
}
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
private getRenderableElementsMap({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElement: AppState["newElement"];
|
||||
}) {
|
||||
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
|
||||
const newElementCanvasElement = newElement?.frameId ? null : newElement;
|
||||
|
||||
for (const element of elements) {
|
||||
if (newElementCanvasElement?.id === element.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// we don't want to render text element that's being currently edited
|
||||
// (it's rendered on remote only)
|
||||
if (
|
||||
!editingTextElement ||
|
||||
editingTextElement.type !== "text" ||
|
||||
element.id !== editingTextElement.id
|
||||
) {
|
||||
elementsMap.set(element.id, element);
|
||||
}
|
||||
}
|
||||
return { elementsMap, newElementCanvasElement };
|
||||
}
|
||||
|
||||
private sortSelectedElementsIntoHighlightedFrame<
|
||||
T extends ExcalidrawElement,
|
||||
>({
|
||||
visibleElements,
|
||||
selectedElements,
|
||||
frameToHighlight,
|
||||
}: {
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly T[];
|
||||
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement>;
|
||||
}): readonly T[] {
|
||||
if (!selectedElements.length) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
// we assume all selected elements are eligible frame children if
|
||||
// frameToHighlight is defined
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
// thus, all deselected elements are the ones we won't reorder
|
||||
const deselectedElements = visibleElements.filter(
|
||||
(element) => !selectedElementsMap.has(element.id),
|
||||
);
|
||||
|
||||
const insertionIndex = getFrameChildrenInsertionIndex(
|
||||
deselectedElements,
|
||||
frameToHighlight.id,
|
||||
);
|
||||
|
||||
if (insertionIndex === null) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
return [
|
||||
...deselectedElements.slice(0, insertionIndex),
|
||||
...selectedElements,
|
||||
...deselectedElements.slice(insertionIndex),
|
||||
] as readonly T[];
|
||||
}
|
||||
|
||||
private _getRenderableElements = memoize(
|
||||
({
|
||||
canvasNonce,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
@@ -33,70 +166,27 @@ export class Renderer {
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
}: {
|
||||
elementsMap: NonDeletedElementsMap;
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
}): readonly NonDeletedExcalidrawElement[] => {
|
||||
const visibleElements: NonDeletedExcalidrawElement[] = [];
|
||||
for (const element of elementsMap.values()) {
|
||||
if (
|
||||
isElementInViewport(
|
||||
element,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
},
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
visibleElements.push(element);
|
||||
}
|
||||
}
|
||||
return visibleElements;
|
||||
};
|
||||
|
||||
const getRenderableElements = ({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElementId: ExcalidrawElement["id"] | undefined;
|
||||
newElement,
|
||||
}: Omit<
|
||||
GetRenderableElementsOpts,
|
||||
| "selectedElements"
|
||||
| "selectedElementsAreBeingDragged"
|
||||
| "frameToHighlight"
|
||||
> & {
|
||||
canvasNonce: string;
|
||||
}) => {
|
||||
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
|
||||
for (const element of elements) {
|
||||
if (newElementId === element.id) {
|
||||
continue;
|
||||
}
|
||||
const { elementsMap, newElementCanvasElement } =
|
||||
this.getRenderableElementsMap({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
});
|
||||
|
||||
// we don't want to render text element that's being currently edited
|
||||
// (it's rendered on remote only)
|
||||
if (
|
||||
!editingTextElement ||
|
||||
editingTextElement.type !== "text" ||
|
||||
element.id !== editingTextElement.id
|
||||
) {
|
||||
elementsMap.set(element.id, element);
|
||||
}
|
||||
}
|
||||
return elementsMap;
|
||||
};
|
||||
|
||||
return memoize(
|
||||
({
|
||||
const visibleElements = this.getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
@@ -104,52 +194,69 @@ export class Renderer {
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
// cache-invalidation nonce
|
||||
sceneNonce: _sceneNonce,
|
||||
}: {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
/** note: first render of newElement will always bust the cache
|
||||
* (we'd have to prefilter elements outside of this function) */
|
||||
newElementId: ExcalidrawElement["id"] | undefined;
|
||||
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
|
||||
}) => {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
});
|
||||
|
||||
const elementsMap = getRenderableElements({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElementId,
|
||||
return {
|
||||
elementsMap,
|
||||
visibleElements,
|
||||
newElementCanvasElement,
|
||||
canvasNonce,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
public getRenderableElements = (opts: GetRenderableElementsOpts) => {
|
||||
const { newElement } = opts;
|
||||
const canvasNonce = `${this.scene.getSceneNonce()}${
|
||||
newElement?.frameId ? `:${newElement.versionNonce}` : ""
|
||||
}`;
|
||||
|
||||
const ret = this._getRenderableElements({
|
||||
canvasNonce,
|
||||
|
||||
// don't spread `opts` because we don't want to memoize on some props
|
||||
|
||||
zoom: opts.zoom,
|
||||
offsetLeft: opts.offsetLeft,
|
||||
offsetTop: opts.offsetTop,
|
||||
scrollX: opts.scrollX,
|
||||
scrollY: opts.scrollY,
|
||||
height: opts.height,
|
||||
width: opts.width,
|
||||
editingTextElement: opts.editingTextElement,
|
||||
newElement: opts.newElement,
|
||||
});
|
||||
|
||||
// if we're dragging elements over a frame, reorder the selected elements
|
||||
// inside the frame during render (we don't set the `element.frameId` until
|
||||
// pointerup else we'd have to painstainly restore the orig index if user
|
||||
// didn't end up adding elements to the frame)
|
||||
if (
|
||||
opts.frameToHighlight &&
|
||||
opts.selectedElementsAreBeingDragged &&
|
||||
// if all dragged elements are already in the frame, don't reorder
|
||||
getCommonFrameId(opts.selectedElements) !== opts.frameToHighlight.id
|
||||
) {
|
||||
const reorderedVisibleElements =
|
||||
this.sortSelectedElementsIntoHighlightedFrame({
|
||||
visibleElements: ret.visibleElements,
|
||||
selectedElements: opts.selectedElements,
|
||||
frameToHighlight: opts.frameToHighlight,
|
||||
});
|
||||
|
||||
const visibleElements = getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
});
|
||||
return {
|
||||
...ret,
|
||||
visibleElements: reorderedVisibleElements,
|
||||
};
|
||||
}
|
||||
|
||||
return { elementsMap, visibleElements };
|
||||
},
|
||||
);
|
||||
})();
|
||||
return ret;
|
||||
};
|
||||
|
||||
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
|
||||
// safe to break TS contract here (for upstream cases)
|
||||
public destroy() {
|
||||
renderStaticSceneThrottled.cancel();
|
||||
this.getRenderableElements.clear();
|
||||
this._getRenderableElements.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
isCloseTo,
|
||||
line,
|
||||
linesIntersectAt,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
rangeInclusive,
|
||||
@@ -17,7 +13,7 @@ import {
|
||||
getDraggedElementsBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element";
|
||||
import { isBoundToContainer, isElbowArrow } from "@excalidraw/element";
|
||||
import { isBoundToContainer } from "@excalidraw/element";
|
||||
|
||||
import { getMaximumGroups } from "@excalidraw/element";
|
||||
|
||||
@@ -33,18 +29,14 @@ import type { MaybeTransformHandleType } from "@excalidraw/element";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
KeyboardModifiersObject,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
} from "./types";
|
||||
|
||||
const SNAP_DISTANCE = 8;
|
||||
|
||||
@@ -130,11 +122,6 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
|
||||
export class SnapCache {
|
||||
private static referenceSnapPoints: GlobalPoint[] | null = null;
|
||||
|
||||
private static linearElementAxisSnapTargets: {
|
||||
editingElementId: ExcalidrawElement["id"];
|
||||
snapTargets: GlobalPoint[];
|
||||
} | null = null;
|
||||
|
||||
private static visibleGaps: {
|
||||
verticalGaps: Gap[];
|
||||
horizontalGaps: Gap[];
|
||||
@@ -148,27 +135,6 @@ export class SnapCache {
|
||||
return SnapCache.referenceSnapPoints;
|
||||
};
|
||||
|
||||
public static setLinearElementAxisSnapTargets = (
|
||||
editingElementId: ExcalidrawElement["id"],
|
||||
snapTargets: GlobalPoint[] | null,
|
||||
) => {
|
||||
SnapCache.linearElementAxisSnapTargets = snapTargets
|
||||
? {
|
||||
editingElementId,
|
||||
snapTargets,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
public static getLinearElementAxisSnapTargets = (
|
||||
editingElementId: ExcalidrawElement["id"],
|
||||
) => {
|
||||
return SnapCache.linearElementAxisSnapTargets?.editingElementId ===
|
||||
editingElementId
|
||||
? SnapCache.linearElementAxisSnapTargets.snapTargets
|
||||
: null;
|
||||
};
|
||||
|
||||
public static setVisibleGaps = (
|
||||
gaps: {
|
||||
verticalGaps: Gap[];
|
||||
@@ -184,7 +150,6 @@ export class SnapCache {
|
||||
|
||||
public static destroy = () => {
|
||||
SnapCache.referenceSnapPoints = null;
|
||||
SnapCache.linearElementAxisSnapTargets = null;
|
||||
SnapCache.visibleGaps = null;
|
||||
};
|
||||
}
|
||||
@@ -270,19 +235,6 @@ export const getElementsCorners = (
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
|
||||
if (
|
||||
(element.type === "line" || element.type === "arrow") &&
|
||||
!boundingBoxCorners
|
||||
) {
|
||||
// For linear elements, use actual points instead of bounding box
|
||||
const linearPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element as NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap,
|
||||
{
|
||||
dragOffset,
|
||||
},
|
||||
);
|
||||
result = linearPoints;
|
||||
} else if (
|
||||
(element.type === "diamond" || element.type === "ellipse") &&
|
||||
!boundingBoxCorners
|
||||
) {
|
||||
@@ -681,227 +633,6 @@ export const getReferenceSnapPoints = (
|
||||
.flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap));
|
||||
};
|
||||
|
||||
const getExternalAxisSnapTargets = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
editingElement: ExcalidrawLinearElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const cachedAxisSnapTargets = SnapCache.getLinearElementAxisSnapTargets(
|
||||
editingElement.id,
|
||||
);
|
||||
|
||||
const externalAxisSnapTargets =
|
||||
cachedAxisSnapTargets ??
|
||||
getReferenceSnapPoints(elements, [editingElement], appState, elementsMap);
|
||||
|
||||
if (!cachedAxisSnapTargets) {
|
||||
SnapCache.setLinearElementAxisSnapTargets(
|
||||
editingElement.id,
|
||||
externalAxisSnapTargets,
|
||||
);
|
||||
}
|
||||
|
||||
return externalAxisSnapTargets;
|
||||
};
|
||||
|
||||
const getOwnAxisSnapTargets = (
|
||||
editingElement: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
selectedPointsIndices?: readonly number[],
|
||||
) => {
|
||||
return LinearElementEditor.getPointsGlobalCoordinates(
|
||||
editingElement as NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap,
|
||||
{
|
||||
excludePointsIndices: selectedPointsIndices,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getAxisSnapTargets = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
editingElement: ExcalidrawLinearElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
options: {
|
||||
includeSelfPoints?: boolean;
|
||||
selectedPointsIndices?: readonly number[];
|
||||
} = {},
|
||||
) => {
|
||||
const externalAxisSnapTargets = getExternalAxisSnapTargets(
|
||||
elements,
|
||||
editingElement,
|
||||
appState,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (!options.includeSelfPoints) {
|
||||
return externalAxisSnapTargets;
|
||||
}
|
||||
|
||||
return externalAxisSnapTargets.concat(
|
||||
getOwnAxisSnapTargets(
|
||||
editingElement,
|
||||
elementsMap,
|
||||
options.selectedPointsIndices,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const collectNearestAxisSnapCandidates = (
|
||||
axisSnapTargets: readonly GlobalPoint[],
|
||||
pointerPosition: GlobalPoint,
|
||||
nearestSnapsX: Snaps,
|
||||
nearestSnapsY: Snaps,
|
||||
minOffset: Vector2D,
|
||||
) => {
|
||||
for (const snapTarget of axisSnapTargets) {
|
||||
const offsetX = snapTarget[0] - pointerPosition[0];
|
||||
const offsetY = snapTarget[1] - pointerPosition[1];
|
||||
const absOffsetX = Math.abs(offsetX);
|
||||
const absOffsetY = Math.abs(offsetY);
|
||||
|
||||
if (absOffsetX > minOffset.x && absOffsetY > minOffset.y) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (absOffsetX <= minOffset.x) {
|
||||
if (absOffsetX < minOffset.x) {
|
||||
nearestSnapsX.length = 0;
|
||||
}
|
||||
|
||||
nearestSnapsX.push({
|
||||
type: "point",
|
||||
points: [pointerPosition, snapTarget],
|
||||
offset: offsetX,
|
||||
});
|
||||
|
||||
minOffset.x = absOffsetX;
|
||||
}
|
||||
|
||||
if (absOffsetY <= minOffset.y) {
|
||||
if (absOffsetY < minOffset.y) {
|
||||
nearestSnapsY.length = 0;
|
||||
}
|
||||
|
||||
nearestSnapsY.push({
|
||||
type: "point",
|
||||
points: [pointerPosition, snapTarget],
|
||||
offset: offsetY,
|
||||
});
|
||||
|
||||
minOffset.y = absOffsetY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const snapLinearElementPoint = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
editingElement: ExcalidrawLinearElement,
|
||||
pointerPosition: GlobalPoint,
|
||||
app: AppClassProperties,
|
||||
event: KeyboardModifiersObject,
|
||||
elementsMap: ElementsMap,
|
||||
options: {
|
||||
includeExternalPoints?: boolean;
|
||||
includeSelfPoints?: boolean;
|
||||
selectedPointsIndices?: readonly number[];
|
||||
} = {},
|
||||
) => {
|
||||
if (
|
||||
!isSnappingEnabled({ app, event, selectedElements: [editingElement] }) ||
|
||||
isElbowArrow(editingElement)
|
||||
) {
|
||||
return {
|
||||
snapOffset: { x: 0, y: 0 },
|
||||
snapLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
const snapDistance = getSnapDistance(app.state.zoom.value);
|
||||
const minOffset = {
|
||||
x: snapDistance,
|
||||
y: snapDistance,
|
||||
};
|
||||
|
||||
const nearestSnapsX: Snaps = [];
|
||||
const nearestSnapsY: Snaps = [];
|
||||
|
||||
if (options.includeExternalPoints !== false) {
|
||||
collectNearestAxisSnapCandidates(
|
||||
getExternalAxisSnapTargets(
|
||||
elements,
|
||||
editingElement,
|
||||
app.state,
|
||||
elementsMap,
|
||||
),
|
||||
pointerPosition,
|
||||
nearestSnapsX,
|
||||
nearestSnapsY,
|
||||
minOffset,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.includeSelfPoints) {
|
||||
collectNearestAxisSnapCandidates(
|
||||
getOwnAxisSnapTargets(
|
||||
editingElement,
|
||||
elementsMap,
|
||||
options.selectedPointsIndices,
|
||||
),
|
||||
pointerPosition,
|
||||
nearestSnapsX,
|
||||
nearestSnapsY,
|
||||
minOffset,
|
||||
);
|
||||
}
|
||||
|
||||
const snapOffset = {
|
||||
x: nearestSnapsX[0]?.offset ?? 0,
|
||||
y: nearestSnapsY[0]?.offset ?? 0,
|
||||
};
|
||||
|
||||
// Create snap lines using the snapped position (fixed position)
|
||||
let pointSnapLines: SnapLine[] = [];
|
||||
|
||||
if (snapOffset.x !== 0 || snapOffset.y !== 0) {
|
||||
const snappedPosition = pointFrom<GlobalPoint>(
|
||||
pointerPosition[0] + snapOffset.x,
|
||||
pointerPosition[1] + snapOffset.y,
|
||||
);
|
||||
|
||||
const snappedSnapsX = nearestSnapsX
|
||||
.filter(
|
||||
(snap): snap is PointSnap =>
|
||||
snap.type === "point" && isCloseTo(snap.offset, snapOffset.x, 0.01),
|
||||
)
|
||||
.map((snap) => ({
|
||||
type: "point" as const,
|
||||
points: [snappedPosition, snap.points[1]] as [GlobalPoint, GlobalPoint],
|
||||
offset: 0,
|
||||
}));
|
||||
|
||||
const snappedSnapsY = nearestSnapsY
|
||||
.filter(
|
||||
(snap): snap is PointSnap =>
|
||||
snap.type === "point" && isCloseTo(snap.offset, snapOffset.y, 0.01),
|
||||
)
|
||||
.map((snap) => ({
|
||||
type: "point" as const,
|
||||
points: [snappedPosition, snap.points[1]] as [GlobalPoint, GlobalPoint],
|
||||
offset: 0,
|
||||
}));
|
||||
|
||||
pointSnapLines = createPointSnapLines(snappedSnapsX, snappedSnapsY);
|
||||
}
|
||||
|
||||
return {
|
||||
snapOffset,
|
||||
snapLines: pointSnapLines,
|
||||
};
|
||||
};
|
||||
|
||||
const getPointSnaps = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
selectionSnapPoints: GlobalPoint[],
|
||||
@@ -1681,79 +1412,3 @@ export const isActiveToolNonLinearSnappable = (
|
||||
activeToolType === TOOL_TYPE.text
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Snaps to discrete angle rotation logic.
|
||||
* This function handles the common pattern of finding intersections between
|
||||
* angle lines and snap lines, and updating the snap lines accordingly.
|
||||
*
|
||||
* @param snapLines - The original snap lines from snapping
|
||||
* @param angleLine - The line representing the discrete angle constraint
|
||||
* @param gridPosition - The grid position (original pointer position)
|
||||
* @param referencePosition - The reference position (usually the start point)
|
||||
* @returns Object containing updated snap lines and position deltas
|
||||
*/
|
||||
export const snapToDiscreteAngle = (
|
||||
snapLines: SnapLine[],
|
||||
angleLine: [GlobalPoint, GlobalPoint],
|
||||
gridPosition: GlobalPoint,
|
||||
referencePosition: GlobalPoint,
|
||||
): {
|
||||
snapLines: SnapLine[];
|
||||
dxFromReference: number;
|
||||
dyFromReference: number;
|
||||
} => {
|
||||
if (snapLines.length === 0) {
|
||||
return {
|
||||
snapLines: [],
|
||||
dxFromReference: gridPosition[0] - referencePosition[0],
|
||||
dyFromReference: gridPosition[1] - referencePosition[1],
|
||||
};
|
||||
}
|
||||
|
||||
const firstSnapLine = snapLines[0];
|
||||
if (firstSnapLine.type === "points" && firstSnapLine.points.length > 1) {
|
||||
const snapLine = line(firstSnapLine.points[0], firstSnapLine.points[1]);
|
||||
const intersection = linesIntersectAt<GlobalPoint>(
|
||||
line(angleLine[0], angleLine[1]),
|
||||
snapLine,
|
||||
);
|
||||
|
||||
if (intersection) {
|
||||
const dxFromReference = intersection[0] - referencePosition[0];
|
||||
const dyFromReference = intersection[1] - referencePosition[1];
|
||||
|
||||
const furthestPoint = firstSnapLine.points.reduce(
|
||||
(furthest, point) => {
|
||||
const distance = pointDistance(intersection, point);
|
||||
if (distance > furthest.distance) {
|
||||
return { point, distance };
|
||||
}
|
||||
return furthest;
|
||||
},
|
||||
{
|
||||
point: firstSnapLine.points[0],
|
||||
distance: pointDistance(intersection, firstSnapLine.points[0]),
|
||||
},
|
||||
);
|
||||
|
||||
const updatedSnapLine: PointSnapLine = {
|
||||
type: "points",
|
||||
points: [furthestPoint.point, intersection],
|
||||
};
|
||||
|
||||
return {
|
||||
snapLines: [updatedSnapLine],
|
||||
dxFromReference,
|
||||
dyFromReference,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If no intersection found, return original snap lines with grid position
|
||||
return {
|
||||
snapLines,
|
||||
dxFromReference: gridPosition[0] - referencePosition[0],
|
||||
dyFromReference: gridPosition[1] - referencePosition[1],
|
||||
};
|
||||
};
|
||||
@@ -83,6 +83,26 @@ mockMermaidToExcalidraw({
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeDialogSnapshot = (dialog: Element) => {
|
||||
const dialogClone = dialog.cloneNode(true) as HTMLElement;
|
||||
|
||||
dialogClone
|
||||
.querySelectorAll<HTMLElement>(".ttd-dialog-content")
|
||||
.forEach((element) => {
|
||||
// Radix Tabs injects this during initial mount animation prevention.
|
||||
// Its presence depends on render timing and is unrelated to this test.
|
||||
if (element.style.animationDuration === "0s") {
|
||||
element.style.removeProperty("animation-duration");
|
||||
}
|
||||
|
||||
if (!element.getAttribute("style")) {
|
||||
element.removeAttribute("style");
|
||||
}
|
||||
});
|
||||
|
||||
return dialogClone.outerHTML;
|
||||
};
|
||||
|
||||
describe("Test <MermaidToExcalidraw/>", () => {
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
@@ -99,7 +119,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
await waitFor(() => expect(dialog.querySelector("canvas")).not.toBeNull());
|
||||
expect(dialog.outerHTML).toMatchSnapshot();
|
||||
expect(normalizeDialogSnapshot(dialog)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show error in preview when mermaid library throws error", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
|
||||
"flowchart TD
|
||||
|
||||
@@ -979,7 +979,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1173,7 +1172,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1388,7 +1386,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1720,7 +1717,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2052,7 +2048,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2265,7 +2260,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2509,7 +2503,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2813,7 +2806,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3181,7 +3173,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3675,7 +3666,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3999,7 +3989,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4326,7 +4315,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5612,7 +5600,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -6832,7 +6819,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7786,7 +7772,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8786,7 +8771,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9781,7 +9765,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
|
||||
@@ -98,7 +98,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -732,7 +731,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1293,7 +1291,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1654,7 +1651,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2017,7 +2013,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2280,7 +2275,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2380,7 +2374,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"endBinding": {
|
||||
"elementId": "id1",
|
||||
"fixedPoint": [
|
||||
0,
|
||||
"0.50010",
|
||||
"0.50010",
|
||||
],
|
||||
"mode": "orbit",
|
||||
@@ -2388,7 +2382,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "439.20000",
|
||||
"height": "399.26547",
|
||||
"id": "id4",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -2402,8 +2396,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
488,
|
||||
"-439.20000",
|
||||
"488.00000",
|
||||
"-399.26547",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -2425,9 +2419,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"width": 488,
|
||||
"width": "488.00000",
|
||||
"x": 6,
|
||||
"y": "-5.39000",
|
||||
"y": "-4.89900",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2548,7 +2542,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"endBinding": {
|
||||
"elementId": "id1",
|
||||
"fixedPoint": [
|
||||
0,
|
||||
"0.50010",
|
||||
"0.50010",
|
||||
],
|
||||
"mode": "orbit",
|
||||
@@ -2556,7 +2550,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "439.20000",
|
||||
"height": "399.26547",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -2568,8 +2562,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
488,
|
||||
"-439.20000",
|
||||
"488.00000",
|
||||
"-399.26547",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -2590,9 +2584,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"version": 11,
|
||||
"width": 488,
|
||||
"width": "488.00000",
|
||||
"x": 6,
|
||||
"y": "-5.39000",
|
||||
"y": "-4.89900",
|
||||
},
|
||||
"inserted": {
|
||||
"isDeleted": true,
|
||||
@@ -2735,7 +2729,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3039,7 +3032,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3359,7 +3351,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3654,7 +3645,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3941,7 +3931,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4177,7 +4166,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4435,7 +4423,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4707,7 +4694,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4937,7 +4923,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5167,7 +5152,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5415,7 +5399,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5672,7 +5655,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5930,7 +5912,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -6260,7 +6241,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -6691,7 +6671,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7072,7 +7051,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7374,7 +7352,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7667,7 +7644,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7898,7 +7874,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8251,7 +8226,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8610,7 +8584,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9011,7 +8984,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9293,7 +9265,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9558,7 +9529,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9824,7 +9794,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10058,7 +10027,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10355,7 +10323,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10671,7 +10638,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10911,7 +10877,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -11836,7 +11801,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -12095,7 +12059,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -12333,7 +12296,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -12569,7 +12531,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -12966,7 +12927,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13174,7 +13134,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13386,7 +13345,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13685,7 +13643,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13984,7 +13941,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14229,7 +14185,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14467,7 +14422,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14705,7 +14659,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14953,7 +14906,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -15286,7 +15238,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -15459,7 +15410,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -15742,7 +15692,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -16006,7 +15955,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -16161,7 +16109,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -16443,7 +16390,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -16607,7 +16553,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -17356,7 +17301,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -18003,7 +17947,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -18648,7 +18591,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -19401,7 +19343,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -20170,7 +20111,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -20653,7 +20593,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -21160,7 +21099,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -21622,7 +21560,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
|
||||
@@ -106,7 +106,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -535,7 +534,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -943,7 +941,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1510,7 +1507,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -1723,7 +1719,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2105,7 +2100,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2349,7 +2343,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2532,7 +2525,6 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -2856,7 +2848,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3114,7 +3105,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3356,7 +3346,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3593,7 +3582,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -3853,7 +3841,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4167,7 +4154,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4631,7 +4617,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -4887,7 +4872,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5191,7 +5175,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5372,7 +5355,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5573,7 +5555,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -5971,7 +5952,6 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -6263,7 +6243,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7057,7 +7036,6 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7392,7 +7370,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7671,7 +7648,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -7907,7 +7883,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8146,7 +8121,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8327,7 +8301,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8508,7 +8481,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8665,14 +8637,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
},
|
||||
"originSnapOffset": null,
|
||||
"penDetected": false,
|
||||
"penMode": false,
|
||||
"preferredSelectionTool": {
|
||||
@@ -8726,7 +8691,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -8959,7 +8923,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9158,7 +9121,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9329,14 +9291,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
},
|
||||
"originSnapOffset": null,
|
||||
"penDetected": false,
|
||||
"penMode": false,
|
||||
"preferredSelectionTool": {
|
||||
@@ -9390,7 +9345,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9593,7 +9547,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -9804,7 +9757,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10005,7 +9957,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10184,7 +10135,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10379,7 +10329,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -10568,7 +10517,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -11094,7 +11042,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -11371,7 +11318,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -11497,7 +11443,6 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -11702,7 +11647,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -12024,7 +11968,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -12458,7 +12401,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13090,7 +13032,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13218,7 +13159,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -13879,7 +13819,6 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14218,7 +14157,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14451,7 +14389,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14577,7 +14514,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -14940,7 +14876,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
@@ -15067,7 +15002,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnimationController } from "../renderer/animation";
|
||||
|
||||
const FIRST_KEY = "animation-test-first";
|
||||
const SECOND_KEY = "animation-test-second";
|
||||
|
||||
describe("AnimationController", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
AnimationController.cancel(SECOND_KEY);
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts a new animation after the previous last animation was cancelled", async () => {
|
||||
let firstFrames = 0;
|
||||
AnimationController.start(FIRST_KEY, () => {
|
||||
firstFrames++;
|
||||
return { keep: true };
|
||||
});
|
||||
|
||||
expect(firstFrames).toBe(1);
|
||||
|
||||
AnimationController.cancel(FIRST_KEY);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
let secondFrames = 0;
|
||||
AnimationController.start(SECOND_KEY, () => {
|
||||
secondFrames++;
|
||||
return secondFrames === 1 ? { keep: true } : null;
|
||||
});
|
||||
|
||||
expect(secondFrames).toBe(1);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(secondFrames).toBe(2);
|
||||
expect(AnimationController.running(SECOND_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels a frame scheduled during a tick if no animations remain", async () => {
|
||||
let firstFrames = 0;
|
||||
let secondFrames = 0;
|
||||
|
||||
AnimationController.start(FIRST_KEY, ({ state }) => {
|
||||
if (!state) {
|
||||
return { keep: true };
|
||||
}
|
||||
|
||||
firstFrames++;
|
||||
|
||||
AnimationController.start(SECOND_KEY, () => {
|
||||
secondFrames++;
|
||||
return { keep: true };
|
||||
});
|
||||
AnimationController.cancel(SECOND_KEY);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(firstFrames).toBe(1);
|
||||
expect(secondFrames).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -305,8 +305,88 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[0].index! < frame.index!).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should layer pasted elements above the highest frame child", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
|
||||
API.setElements([frameChild, frame]);
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rect],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(50, 50);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements.map((element) => element.id)).toEqual([
|
||||
frameChild.id,
|
||||
h.elements[1].id,
|
||||
frame.id,
|
||||
]);
|
||||
expect(h.elements[1].index! > frameChild.index!).toBe(true);
|
||||
expect(h.elements[1].index! < frame.index!).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve denormalized pasted frame child order", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [frame, frameChild],
|
||||
files: null,
|
||||
});
|
||||
|
||||
mouse.moveTo(200, 200);
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[0].type).toBe(frame.type);
|
||||
expect(h.elements[1].type).toBe(frameChild.type);
|
||||
expect(h.elements[1].frameId).toBe(h.elements[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,8 +459,9 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(null);
|
||||
});
|
||||
@@ -422,10 +503,11 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].type).toBe(rect2.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(frame.id);
|
||||
expect(h.elements[2].id).toBe(frame.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,8 +555,9 @@ describe("pasting & frames", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(4);
|
||||
expect(h.elements[1].type).toBe(rect.type);
|
||||
expect(h.elements[1].frameId).toBe(frame.id);
|
||||
expect(h.elements[0].type).toBe(rect.type);
|
||||
expect(h.elements[0].frameId).toBe(frame.id);
|
||||
expect(h.elements[1].id).toBe(frame.id);
|
||||
expect(h.elements[2].type).toBe(rect2.type);
|
||||
expect(h.elements[2].frameId).toBe(h.elements[3].id);
|
||||
expect(h.elements[3].type).toBe(frame2.type);
|
||||
|
||||
@@ -160,6 +160,39 @@ describe("restoreElements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore only valid freedraw points and keep pressures aligned", () => {
|
||||
const freedrawElement = API.createElement({
|
||||
type: "freedraw",
|
||||
id: "id-freedraw-invalid-points",
|
||||
points: [pointFrom(0, 0), pointFrom(10, 10)],
|
||||
});
|
||||
|
||||
const restoredFreedraw = restore.restoreElements(
|
||||
[
|
||||
{
|
||||
...freedrawElement,
|
||||
simulatePressure: false,
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
[Infinity, 10],
|
||||
null,
|
||||
pointFrom(20, 20),
|
||||
[NaN, 30],
|
||||
[40, null],
|
||||
],
|
||||
pressures: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
|
||||
} as any,
|
||||
],
|
||||
null,
|
||||
)[0] as ExcalidrawFreeDrawElement;
|
||||
|
||||
expect(restoredFreedraw.points).toEqual([
|
||||
pointFrom(0, 0),
|
||||
pointFrom(20, 20),
|
||||
]);
|
||||
expect(restoredFreedraw.pressures).toEqual([0.1, 0.4]);
|
||||
});
|
||||
|
||||
it("should restore line and draw elements correctly", () => {
|
||||
const lineElement = API.createElement({ type: "line", id: "id-line01" });
|
||||
|
||||
@@ -400,6 +433,52 @@ describe("restoreElements", () => {
|
||||
expect(restoredLine.points).toMatchObject(expectedLinePoints);
|
||||
});
|
||||
|
||||
it("should restore only valid linear points", () => {
|
||||
const lineElement: any = API.createElement({
|
||||
type: "line",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200,
|
||||
});
|
||||
const arrowElement: any = API.createElement({
|
||||
type: "arrow",
|
||||
width: 100,
|
||||
height: 200,
|
||||
});
|
||||
|
||||
lineElement.points = [
|
||||
[2, 3],
|
||||
null,
|
||||
[Infinity, 4],
|
||||
[5, 7],
|
||||
[NaN, 8],
|
||||
[9, null],
|
||||
];
|
||||
arrowElement.points = [
|
||||
[null, 0],
|
||||
[Infinity, 4],
|
||||
];
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[lineElement, arrowElement],
|
||||
null,
|
||||
);
|
||||
const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
|
||||
const restoredArrow = restoredElements[1] as ExcalidrawArrowElement;
|
||||
|
||||
expect(restoredLine.points).toEqual([pointFrom(0, 0), pointFrom(3, 4)]);
|
||||
expect(restoredLine.x).toBe(12);
|
||||
expect(restoredLine.y).toBe(23);
|
||||
expect(restoredLine.width).toBe(3);
|
||||
expect(restoredLine.height).toBe(4);
|
||||
|
||||
expect(restoredArrow.points).toEqual([
|
||||
pointFrom(0, 0),
|
||||
pointFrom(100, 200),
|
||||
]);
|
||||
});
|
||||
|
||||
it("when the number of points of a line is greater or equal 2", () => {
|
||||
const lineElement_0 = API.createElement({
|
||||
type: "line",
|
||||
|
||||
@@ -94,3 +94,42 @@ export const testPolyfills = {
|
||||
// https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
|
||||
URL,
|
||||
};
|
||||
|
||||
export const PolyfillLocalStorage = () => {
|
||||
// Node.js 25+ provides a native localStorage global that shadows jsdom's,
|
||||
// and jsdom's own localStorage also uses the native one -- both are broken
|
||||
// (empty objects without Storage methods). On older Node versions, jsdom
|
||||
// provides a working localStorage. This polyfill replaces localStorage on
|
||||
// all supported versions with a standard Storage implementation backed by
|
||||
// a Map, ensuring consistent behavior regardless of the Node.js version.
|
||||
const storage = new Map<string, string>();
|
||||
const storagePolyfill: Storage = {
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
},
|
||||
key(index) {
|
||||
return Array.from(storage.keys())[index] ?? null;
|
||||
},
|
||||
getItem(key) {
|
||||
return storage.get(key) ?? null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
*[Symbol.iterator]() {
|
||||
yield* storage.entries();
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: storagePolyfill,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5132,7 +5132,7 @@ describe("history", () => {
|
||||
}),
|
||||
endBinding: expect.objectContaining({
|
||||
elementId: rect2.id,
|
||||
fixedPoint: expect.arrayContaining([0, 0.5001]),
|
||||
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
|
||||
}),
|
||||
isDeleted: true,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomId, reseed } from "@excalidraw/common";
|
||||
import { MIME_TYPES, randomId, reseed } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
@@ -17,18 +17,41 @@ import {
|
||||
} from "./fixtures/constants";
|
||||
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
|
||||
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
export const setupImageTest = async (
|
||||
sizes: { width: number; height: number }[],
|
||||
props?: ExcalidrawProps,
|
||||
) => {
|
||||
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||
await render(
|
||||
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} {...props} />,
|
||||
);
|
||||
|
||||
h.state.height = 1000;
|
||||
|
||||
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
|
||||
};
|
||||
|
||||
describe("resizeImageFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns the original file when it already fits the max dimensions", async () => {
|
||||
mockMultipleHTMLImageElements([[100, 100]]);
|
||||
|
||||
const imageFile = new File([new Uint8Array([1, 2, 3])], "image.png", {
|
||||
type: MIME_TYPES.png,
|
||||
});
|
||||
|
||||
await expect(
|
||||
blobModule.resizeImageFile(imageFile, { maxWidthOrHeight: 200 }),
|
||||
).resolves.toBe(imageFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image insertion", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -112,4 +135,42 @@ describe("image insertion", () => {
|
||||
|
||||
await assert();
|
||||
});
|
||||
|
||||
it("passes host-configured max image dimensions to the resize helper", async () => {
|
||||
await setupImageTest([DEER_IMAGE_DIMENSIONS], {
|
||||
imageOptions: { maxWidthOrHeight: 2048 },
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{ kind: "file", file: await API.loadFile("./fixtures/deer.png") },
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(blobModule.resizeImageFile).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
{ maxWidthOrHeight: 2048 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces host-configured max image file size", async () => {
|
||||
await setupImageTest([DEER_IMAGE_DIMENSIONS], {
|
||||
imageOptions: { maxFileSizeBytes: 1024 * 1024 },
|
||||
});
|
||||
|
||||
await API.drop([
|
||||
{
|
||||
kind: "file",
|
||||
file: new File([new Uint8Array(2 * 1024 * 1024)], "image.png", {
|
||||
type: MIME_TYPES.png,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.errorMessage).toBe(
|
||||
"File is too big. Maximum allowed size is 1MB.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { API } from "./helpers/api";
|
||||
import { Pointer } from "./helpers/ui";
|
||||
import { act, GlobalTestState, render, waitFor } from "./test-utils";
|
||||
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import type { Collaborator, ExcalidrawProps, SocketId } from "../types";
|
||||
|
||||
describe("laser tool interactions", () => {
|
||||
const h = window.h;
|
||||
@@ -128,4 +128,36 @@ describe("laser tool interactions", () => {
|
||||
expect(h.state.scrollY).toBe(initialScrollY);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
|
||||
});
|
||||
|
||||
it("cleans up remote laser trails when the last collaborator leaves", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
const socketId = "socket-id" as SocketId;
|
||||
const collaborators = new Map<SocketId, Collaborator>([
|
||||
[
|
||||
socketId,
|
||||
{
|
||||
pointer: {
|
||||
x: 10,
|
||||
y: 10,
|
||||
tool: "laser",
|
||||
},
|
||||
button: "down",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const svgLayer = document.querySelector(".SVGLayer svg")!;
|
||||
|
||||
act(() => {
|
||||
h.app.updateScene({ collaborators });
|
||||
});
|
||||
|
||||
expect(svgLayer.querySelectorAll("path")).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
h.app.updateScene({ collaborators: new Map() });
|
||||
});
|
||||
|
||||
expect(svgLayer.querySelectorAll("path")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import * as StaticScene from "../renderer/staticScene";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { render, fireEvent, act, unmountComponent } from "./test-utils";
|
||||
|
||||
import type { Zoom } from "../types";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const renderInteractiveScene = vi.spyOn(
|
||||
@@ -88,6 +90,7 @@ describe("move element", () => {
|
||||
"orbit",
|
||||
"start",
|
||||
h.app.scene,
|
||||
{ value: 1 } as Zoom,
|
||||
);
|
||||
bindBindingElement(
|
||||
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
|
||||
@@ -95,6 +98,7 @@ describe("move element", () => {
|
||||
"orbit",
|
||||
"end",
|
||||
h.app.scene,
|
||||
{ value: 1 } as Zoom,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -111,10 +115,13 @@ describe("move element", () => {
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([200, 0]);
|
||||
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
|
||||
[[106.00000000000001, 55.6867741935484]],
|
||||
[[106, 56.011199999998695]],
|
||||
0,
|
||||
);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
|
||||
[[88, 88.01760000000121]],
|
||||
0,
|
||||
);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0);
|
||||
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
@@ -133,10 +140,13 @@ describe("move element", () => {
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([201, 2]);
|
||||
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
|
||||
[[106, 55.6867741935484]],
|
||||
[[106, 56.011199999998695]],
|
||||
0,
|
||||
);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
|
||||
[[89, 90.01760000000121]],
|
||||
0,
|
||||
);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
|
||||
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
|
||||
import { KEYS, ROUNDNESS, arrayToMap, reseed } from "@excalidraw/common";
|
||||
import {
|
||||
getElementBounds,
|
||||
getElementLineSegments,
|
||||
getElementsWithinSelection,
|
||||
} from "@excalidraw/element";
|
||||
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { SHAPES } from "../components/shapes";
|
||||
@@ -269,6 +273,145 @@ describe("box-selection overlap mode", () => {
|
||||
assertSelectedElements([rect1.id]);
|
||||
});
|
||||
|
||||
it("should select the whole group when overlapping one group member", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
|
||||
API.setElements([rect1, rect2]);
|
||||
|
||||
boxSelect(25, -20, 75, 70);
|
||||
|
||||
assertSelectedElements([rect1.id, rect2.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
});
|
||||
|
||||
it("should return all group elements when overlapping one group member", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect2",
|
||||
x: 100,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const rect3 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect3",
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
const selection = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 125,
|
||||
y: -10,
|
||||
width: 10,
|
||||
height: 70,
|
||||
});
|
||||
const elements = [rect1, rect2, rect3];
|
||||
|
||||
expect(
|
||||
getElementsWithinSelection(
|
||||
elements,
|
||||
selection,
|
||||
arrayToMap([...elements, selection]),
|
||||
false,
|
||||
"overlap",
|
||||
).map((element) => element.id),
|
||||
).toEqual([rect1.id, rect2.id]);
|
||||
});
|
||||
|
||||
it("should retain nested and interleaved group element order", () => {
|
||||
const outerNested1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outerNested1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const other1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "other1",
|
||||
x: 70,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["other"],
|
||||
});
|
||||
const outerOnly = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outerOnly",
|
||||
x: 140,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["outer"],
|
||||
});
|
||||
const other2 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "other2",
|
||||
x: 210,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["other"],
|
||||
});
|
||||
const outerNested2 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outerNested2",
|
||||
x: 280,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const selection = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 295,
|
||||
y: -10,
|
||||
width: 10,
|
||||
height: 70,
|
||||
});
|
||||
const elements = [outerNested1, other1, outerOnly, other2, outerNested2];
|
||||
|
||||
expect(
|
||||
getElementsWithinSelection(
|
||||
elements,
|
||||
selection,
|
||||
arrayToMap([...elements, selection]),
|
||||
false,
|
||||
"overlap",
|
||||
).map((element) => element.id),
|
||||
).toEqual([outerNested1.id, outerOnly.id, outerNested2.id]);
|
||||
});
|
||||
|
||||
it("should not select a transparent rectangle when the selection box stays inside it", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -615,6 +758,32 @@ describe("box-selection overlap mode", () => {
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
|
||||
it("should not select a framed element when selection only overlaps its clipped-out outline", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 200,
|
||||
frameId: frame.id,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([frame, rect1]);
|
||||
|
||||
boxSelect(40, 170, 70, 220);
|
||||
|
||||
assertSelectedElements([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inner box-selection", () => {
|
||||
@@ -690,11 +859,175 @@ describe("inner box-selection", () => {
|
||||
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([rect1.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(40, 40);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([rect2.id, rect3.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not select a nested outer group until all members are contained", async () => {
|
||||
const innerRect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const innerRect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 120,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const outerRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 190,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["outer"],
|
||||
});
|
||||
API.setElements([innerRect1, innerRect2, outerRect]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(0, 0);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(
|
||||
innerRect2.x + innerRect2.width + 10,
|
||||
innerRect2.y + innerRect2.height + 10,
|
||||
);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([]);
|
||||
expect(h.state.selectedGroupIds).toEqual({});
|
||||
});
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(0, 0);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(
|
||||
outerRect.x + outerRect.width + 10,
|
||||
outerRect.y + outerRect.height + 10,
|
||||
);
|
||||
mouse.up();
|
||||
|
||||
assertSelectedElements([innerRect1.id, innerRect2.id, outerRect.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ outer: true });
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("checks nested containment against the current editing depth", async () => {
|
||||
const innerRect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const innerRect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 120,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["inner", "outer"],
|
||||
});
|
||||
const outerRect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 190,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["outer"],
|
||||
});
|
||||
const selection = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 140,
|
||||
height: 70,
|
||||
});
|
||||
const elements = [innerRect1, innerRect2, outerRect];
|
||||
const elementsMap = arrayToMap([...elements, selection]);
|
||||
|
||||
expect(
|
||||
getElementsWithinSelection(
|
||||
elements,
|
||||
selection,
|
||||
elementsMap,
|
||||
false,
|
||||
"contain",
|
||||
).map((element) => element.id),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
getElementsWithinSelection(
|
||||
elements,
|
||||
selection,
|
||||
elementsMap,
|
||||
false,
|
||||
"contain",
|
||||
// "outer", /* editingGroupId - add as param once we implement nested group handling */
|
||||
).map((element) => element.id),
|
||||
).toEqual([innerRect1.id, innerRect2.id]);
|
||||
});
|
||||
|
||||
it("ignores grouped bound text when checking box-selection containment", async () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "container",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
boundElements: [{ type: "text", id: "bound-text" }],
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
id: "bound-text",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 20,
|
||||
containerId: container.id,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
API.setElements([container, boundText, rect]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(40, 40);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(rect.x + rect.width + 10, rect.y + rect.height + 10);
|
||||
mouse.up();
|
||||
|
||||
expect(h.state.selectedElementIds[container.id]).toBe(true);
|
||||
expect(h.state.selectedElementIds[rect.id]).toBe(true);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting & deselecting grouped elements visually nested inside another", async () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -725,7 +1058,7 @@ describe("inner box-selection", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.downAt(rect2.x - 20, rect2.y - 20);
|
||||
mouse.move(-1000, -1000);
|
||||
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10);
|
||||
assertSelectedElements([rect2.id, rect3.id]);
|
||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||
mouse.moveTo(rect2.x - 10, rect2.y - 10);
|
||||
|
||||
@@ -4,10 +4,12 @@ import { resolvablePromise } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { getToolbarTools } from "../components/shapes";
|
||||
|
||||
import { Pointer } from "./helpers/ui";
|
||||
import { act, render } from "./test-utils";
|
||||
|
||||
import type { ExcalidrawImperativeAPI } from "../types";
|
||||
import type { AppClassProperties, ExcalidrawImperativeAPI } from "../types";
|
||||
|
||||
describe("setActiveTool()", () => {
|
||||
const h = window.h;
|
||||
@@ -66,3 +68,27 @@ describe("setActiveTool()", () => {
|
||||
expect(h.state.activeTool.customType).toBe("comment");
|
||||
});
|
||||
});
|
||||
describe("getToolbarTools()", () => {
|
||||
const getToolValues = (preferredSelectionTool: "selection" | "lasso") =>
|
||||
getToolbarTools({
|
||||
state: {
|
||||
preferredSelectionTool: {
|
||||
type: preferredSelectionTool,
|
||||
},
|
||||
},
|
||||
} as AppClassProperties).map((tool) => tool.value);
|
||||
|
||||
it("does not include lasso when selection is preferred", () => {
|
||||
const toolValues = getToolValues("selection");
|
||||
|
||||
expect(toolValues.filter((value) => value === "selection")).toHaveLength(1);
|
||||
expect(toolValues.filter((value) => value === "lasso")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("replaces selection with lasso when lasso is preferred", () => {
|
||||
const toolValues = getToolValues("lasso");
|
||||
|
||||
expect(toolValues.filter((value) => value === "lasso")).toHaveLength(1);
|
||||
expect(toolValues.filter((value) => value === "selection")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@ import type { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import type { MaybeTransformHandleType } from "@excalidraw/element";
|
||||
|
||||
import type { SnapLine } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
PointerType,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -57,6 +55,7 @@ import type { ClipboardData } from "./clipboard";
|
||||
import type App from "./components/App";
|
||||
import type Library from "./data/library";
|
||||
import type { ContextMenuItems } from "./components/ContextMenu";
|
||||
import type { SnapLine } from "./snapping";
|
||||
import type { ImportedDataState } from "./data/types";
|
||||
|
||||
import type { Language } from "./i18n";
|
||||
@@ -316,7 +315,10 @@ export interface AppState {
|
||||
bindingPreference: "enabled" | "disabled";
|
||||
/** user preference whether arrow snap to midpoints while binding */
|
||||
isMidpointSnappingEnabled: boolean;
|
||||
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
|
||||
/**
|
||||
* The bindable element the UI highlights for the user when an arrow is
|
||||
* dragged or otherwise its endpoint being close to said element.
|
||||
*/
|
||||
suggestedBinding: {
|
||||
element: NonDeleted<ExcalidrawBindableElement>;
|
||||
midPoint?: GlobalPoint;
|
||||
@@ -348,8 +350,11 @@ export interface AppState {
|
||||
type: "selection" | "lasso";
|
||||
initialized: boolean;
|
||||
};
|
||||
|
||||
// Pen handling
|
||||
penMode: boolean;
|
||||
penDetected: boolean;
|
||||
|
||||
exportBackground: boolean;
|
||||
exportEmbedScene: boolean;
|
||||
exportWithDarkMode: boolean;
|
||||
@@ -473,6 +478,9 @@ export interface AppState {
|
||||
// as elements are unlocked, we remove the groupId from the elements
|
||||
// and also remove groupId from this map
|
||||
lockedMultiSelections: { [groupId: string]: true };
|
||||
// Stores the current bind mode which is detemined at various points during
|
||||
// a drag operation (like pointer position vs bindable element) but needed
|
||||
// globally for calculating the binding strategy
|
||||
bindMode: BindMode;
|
||||
}
|
||||
|
||||
@@ -488,10 +496,7 @@ export type SearchMatch = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export type UIAppState = Omit<
|
||||
AppState,
|
||||
"startBoundElement" | "cursorButton" | "scrollX" | "scrollY"
|
||||
>;
|
||||
export type UIAppState = Omit<AppState, "cursorButton" | "scrollX" | "scrollY">;
|
||||
|
||||
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
|
||||
|
||||
@@ -640,6 +645,10 @@ export interface ExcalidrawProps {
|
||||
appState: UIAppState,
|
||||
) => JSX.Element;
|
||||
UIOptions?: Partial<UIOptions>;
|
||||
/**
|
||||
* dimensions and size constraints for inserted images
|
||||
*/
|
||||
imageOptions?: ImageOptions;
|
||||
detectScroll?: boolean;
|
||||
handleKeyboardGlobally?: boolean;
|
||||
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
|
||||
@@ -726,6 +735,11 @@ export type ExportOpts = {
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
export type ImageOptions = Partial<{
|
||||
maxWidthOrHeight: number;
|
||||
maxFileSizeBytes: number;
|
||||
}>;
|
||||
|
||||
// NOTE at the moment, if action name corresponds to canvasAction prop, its
|
||||
// truthiness value will determine whether the action is rendered or not
|
||||
// (see manager renderAction). We also override canvasAction values in
|
||||
@@ -767,6 +781,7 @@ export type AppProps = Merge<
|
||||
canvasActions: Required<CanvasActions> & { export: ExportOpts };
|
||||
}
|
||||
>;
|
||||
imageOptions: Required<ImageOptions>;
|
||||
detectScroll: boolean;
|
||||
handleKeyboardGlobally: boolean;
|
||||
isCollaborating: boolean;
|
||||
@@ -871,8 +886,13 @@ export type PointerDownState = Readonly<{
|
||||
// Whether selected element(s) were duplicated, might change during the
|
||||
// pointer interaction
|
||||
hasBeenDuplicated: boolean;
|
||||
// Whether the pointer is hitting the common bounding box of selected
|
||||
// elements, which is useful for discriminating between selecitng
|
||||
// the entire selection vs a specific element
|
||||
hasHitCommonBoundingBoxOfSelectedElements: boolean;
|
||||
};
|
||||
// This is determined on the initial pointer down event to
|
||||
// set various interaction modalities
|
||||
withCmdOrCtrl: boolean;
|
||||
drag: {
|
||||
// Might change during the pointer interaction
|
||||
@@ -898,6 +918,7 @@ export type PointerDownState = Readonly<{
|
||||
onKeyUp: null | ((event: KeyboardEvent) => void);
|
||||
};
|
||||
boxSelection: {
|
||||
// If the box selection tool is activated on pointer down
|
||||
hasOccurred: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -769,6 +769,63 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not add bound text to a frame when its container is not a frame child", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 90,
|
||||
height: 75,
|
||||
backgroundColor: "red",
|
||||
});
|
||||
API.setElements([frame, rectangle]);
|
||||
|
||||
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
|
||||
|
||||
const text = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(text.frameId).toBe(null);
|
||||
});
|
||||
|
||||
it("should bind text to a frame child container when single clicking its center", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 90,
|
||||
height: 75,
|
||||
backgroundColor: "red",
|
||||
frameId: frame.id,
|
||||
});
|
||||
API.setElements([rectangle, frame]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(text.frameId).toBe(frame.id);
|
||||
});
|
||||
|
||||
it("should set the text element angle to same as container angle when binding to rotated container", async () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@excalidraw/fractional-indexing",
|
||||
"version": "3.3.0",
|
||||
"description": "Provides functions for generating ordering strings",
|
||||
"type": "module",
|
||||
"types": "./dist/types/fractional-indexing/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"keywords": [
|
||||
"fractional",
|
||||
"indexing",
|
||||
"ordering",
|
||||
"order"
|
||||
],
|
||||
"homepage": "https://github.com/rocicorp/fractional-indexing#readme",
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"author": "arv@rocicorp.dev",
|
||||
"license": "CC0-1.0",
|
||||
"devDependencies": {
|
||||
"prettier": "^2.6.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/fractional-indexing/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Vendored from https://www.npmjs.com/package/fractional-indexing
|
||||
// License: CC0 (no rights reserved).
|
||||
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||
|
||||
export const BASE_62_DIGITS =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
// `a` may be empty string, `b` is null or non-empty string.
|
||||
// `a < b` lexicographically if `b` is non-null.
|
||||
// no trailing zeros allowed.
|
||||
// digits is a string such as '0123456789' for base 10. Digits must be in
|
||||
// ascending character code order!
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string | null | undefined} b
|
||||
* @param {string} digits
|
||||
* @returns {string}
|
||||
*/
|
||||
function midpoint(
|
||||
a: string,
|
||||
b: string | null | undefined,
|
||||
digits: string,
|
||||
): string {
|
||||
const zero = digits[0];
|
||||
if (b != null && a >= b) {
|
||||
throw new Error(`${a} >= ${b}`);
|
||||
}
|
||||
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
|
||||
throw new Error("trailing zero");
|
||||
}
|
||||
if (b) {
|
||||
// remove longest common prefix. pad `a` with 0s as we
|
||||
// go. note that we don't need to pad `b`, because it can't
|
||||
// end before `a` while traversing the common prefix.
|
||||
let n = 0;
|
||||
while ((a[n] || zero) === b[n]) {
|
||||
n++;
|
||||
}
|
||||
if (n > 0) {
|
||||
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
|
||||
}
|
||||
}
|
||||
// first digits (or lack of digit) are different
|
||||
const digitA = a ? digits.indexOf(a[0]) : 0;
|
||||
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
|
||||
if (digitB - digitA > 1) {
|
||||
const midDigit = Math.round(0.5 * (digitA + digitB));
|
||||
return digits[midDigit];
|
||||
}
|
||||
// first digits are consecutive
|
||||
if (b && b.length > 1) {
|
||||
return b.slice(0, 1);
|
||||
}
|
||||
|
||||
// `b` is null or has length 1 (a single digit).
|
||||
// the first digit of `a` is the previous digit to `b`,
|
||||
// or 9 if `b` is null.
|
||||
// given, for example, midpoint('49', '5'), return
|
||||
// '4' + midpoint('9', null), which will become
|
||||
// '4' + '9' + midpoint('', null), which is '495'
|
||||
return digits[digitA] + midpoint(a.slice(1), null, digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} int
|
||||
* @return {void}
|
||||
*/
|
||||
|
||||
function validateInteger(int: string): void {
|
||||
if (int.length !== getIntegerLength(int[0])) {
|
||||
throw new Error(`invalid integer part of order key: ${int}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} head
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
function getIntegerLength(head: string): number {
|
||||
if (head >= "a" && head <= "z") {
|
||||
return head.charCodeAt(0) - "a".charCodeAt(0) + 2;
|
||||
} else if (head >= "A" && head <= "Z") {
|
||||
return "Z".charCodeAt(0) - head.charCodeAt(0) + 2;
|
||||
}
|
||||
|
||||
throw new Error(`invalid order key head: ${head}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
function getIntegerPart(key: string): string {
|
||||
const integerPartLength = getIntegerLength(key[0]);
|
||||
|
||||
if (integerPartLength > key.length) {
|
||||
throw new Error(`invalid order key: ${key}`);
|
||||
}
|
||||
return key.slice(0, integerPartLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} digits
|
||||
* @return {void}
|
||||
*/
|
||||
export function validateOrderKey(
|
||||
key: string,
|
||||
digits: string = BASE_62_DIGITS,
|
||||
): void {
|
||||
const validChars = key.split("").every((char) => digits.includes(char));
|
||||
if (key === `A${digits[0].repeat(26)}` || !validChars) {
|
||||
throw new Error(`invalid order key: ${key}`);
|
||||
}
|
||||
// getIntegerPart will throw if the first character is bad,
|
||||
// or the key is too short. we'd call it to check these things
|
||||
// even if we didn't need the result
|
||||
const i = getIntegerPart(key);
|
||||
const f = key.slice(i.length);
|
||||
if (f.slice(-1) === digits[0]) {
|
||||
throw new Error(`invalid order key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// note that this may return null, as there is a largest integer
|
||||
/**
|
||||
* @param {string} x
|
||||
* @param {string} digits
|
||||
* @return {string | null}
|
||||
*/
|
||||
function incrementInteger(x: string, digits: string): string | null {
|
||||
validateInteger(x);
|
||||
const [head, ...digs] = x.split("");
|
||||
let carry = true;
|
||||
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||
const d = digits.indexOf(digs[i]) + 1;
|
||||
if (d === digits.length) {
|
||||
digs[i] = digits[0];
|
||||
} else {
|
||||
digs[i] = digits[d];
|
||||
carry = false;
|
||||
}
|
||||
}
|
||||
if (carry) {
|
||||
if (head === "Z") {
|
||||
return `a${digits[0]}`;
|
||||
}
|
||||
if (head === "z") {
|
||||
return null;
|
||||
}
|
||||
const h = String.fromCharCode(head.charCodeAt(0) + 1);
|
||||
if (h > "a") {
|
||||
digs.push(digits[0]);
|
||||
} else {
|
||||
digs.pop();
|
||||
}
|
||||
return h + digs.join("");
|
||||
}
|
||||
return head + digs.join("");
|
||||
}
|
||||
|
||||
// note that this may return null, as there is a smallest integer
|
||||
/**
|
||||
* @param {string} x
|
||||
* @param {string} digits
|
||||
* @return {string | null}
|
||||
*/
|
||||
function decrementInteger(x: string, digits: string): string | null {
|
||||
validateInteger(x);
|
||||
const [head, ...digs] = x.split("");
|
||||
let borrow = true;
|
||||
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||
const d = digits.indexOf(digs[i]) - 1;
|
||||
if (d === -1) {
|
||||
digs[i] = digits.slice(-1);
|
||||
} else {
|
||||
digs[i] = digits[d];
|
||||
borrow = false;
|
||||
}
|
||||
}
|
||||
if (borrow) {
|
||||
if (head === "a") {
|
||||
return `Z${digits.slice(-1)}`;
|
||||
}
|
||||
if (head === "A") {
|
||||
return null;
|
||||
}
|
||||
const h = String.fromCharCode(head.charCodeAt(0) - 1);
|
||||
if (h < "Z") {
|
||||
digs.push(digits.slice(-1));
|
||||
} else {
|
||||
digs.pop();
|
||||
}
|
||||
return h + digs.join("");
|
||||
}
|
||||
return head + digs.join("");
|
||||
}
|
||||
|
||||
// `a` is an order key or null (START).
|
||||
// `b` is an order key or null (END).
|
||||
// `a < b` lexicographically if both are non-null.
|
||||
// digits is a string such as '0123456789' for base 10. Digits must be in
|
||||
// ascending character code order!
|
||||
/**
|
||||
* @param {string | null | undefined} a
|
||||
* @param {string | null | undefined} b
|
||||
* @param {string=} digits
|
||||
* @return {string}
|
||||
*/
|
||||
export function generateKeyBetween(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
digits = BASE_62_DIGITS,
|
||||
): string {
|
||||
if (a != null) {
|
||||
validateOrderKey(a, digits);
|
||||
}
|
||||
if (b != null) {
|
||||
validateOrderKey(b, digits);
|
||||
}
|
||||
if (a != null && b != null && a >= b) {
|
||||
throw new Error(`${a} >= ${b}`);
|
||||
}
|
||||
if (a == null) {
|
||||
if (b == null) {
|
||||
return `a${digits[0]}`;
|
||||
}
|
||||
|
||||
const ib = getIntegerPart(b);
|
||||
const fb = b.slice(ib.length);
|
||||
if (ib === `A${digits[0].repeat(26)}`) {
|
||||
return ib + midpoint("", fb, digits);
|
||||
}
|
||||
if (ib < b) {
|
||||
return ib;
|
||||
}
|
||||
const res = decrementInteger(ib, digits);
|
||||
if (res == null) {
|
||||
throw new Error("cannot decrement any more");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (b == null) {
|
||||
const ia = getIntegerPart(a);
|
||||
const fa = a.slice(ia.length);
|
||||
const i = incrementInteger(ia, digits);
|
||||
return i == null ? ia + midpoint(fa, null, digits) : i;
|
||||
}
|
||||
|
||||
const ia = getIntegerPart(a);
|
||||
const fa = a.slice(ia.length);
|
||||
const ib = getIntegerPart(b);
|
||||
const fb = b.slice(ib.length);
|
||||
if (ia === ib) {
|
||||
return ia + midpoint(fa, fb, digits);
|
||||
}
|
||||
const i = incrementInteger(ia, digits);
|
||||
if (i == null) {
|
||||
throw new Error("cannot increment any more");
|
||||
}
|
||||
if (i < b) {
|
||||
return i;
|
||||
}
|
||||
return ia + midpoint(fa, null, digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* same preconditions as generateKeysBetween.
|
||||
* n >= 0.
|
||||
* Returns an array of n distinct keys in sorted order.
|
||||
* If a and b are both null, returns [a0, a1, ...]
|
||||
* If one or the other is null, returns consecutive "integer"
|
||||
* keys. Otherwise, returns relatively short keys between
|
||||
* a and b.
|
||||
* @param {string | null | undefined} a
|
||||
* @param {string | null | undefined} b
|
||||
* @param {number} n
|
||||
* @param {string} digits
|
||||
* @return {string[]}
|
||||
*/
|
||||
export function generateNKeysBetween(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
n: number,
|
||||
digits = BASE_62_DIGITS,
|
||||
): string[] {
|
||||
if (n === 0) {
|
||||
return [];
|
||||
}
|
||||
if (n === 1) {
|
||||
return [generateKeyBetween(a, b, digits)];
|
||||
}
|
||||
if (b == null) {
|
||||
let c = generateKeyBetween(a, b, digits);
|
||||
const result = [c];
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = generateKeyBetween(c, b, digits);
|
||||
result.push(c);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (a == null) {
|
||||
let c = generateKeyBetween(a, b, digits);
|
||||
const result = [c];
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = generateKeyBetween(a, c, digits);
|
||||
result.push(c);
|
||||
}
|
||||
result.reverse();
|
||||
return result;
|
||||
}
|
||||
const mid = Math.floor(n / 2);
|
||||
const c = generateKeyBetween(a, b, digits);
|
||||
return [
|
||||
...generateNKeysBetween(a, c, mid, digits),
|
||||
c,
|
||||
...generateNKeysBetween(c, b, n - mid - 1, digits),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { degreesToRadians } from "./angle";
|
||||
import { PRECISION } from "./utils";
|
||||
import { isFiniteNumber, PRECISION } from "./utils";
|
||||
import { vectorFromPoint, vectorScale } from "./vector";
|
||||
|
||||
import type {
|
||||
@@ -253,3 +253,12 @@ export const isPointWithinBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
q[1] >= Math.min(p[1], r[1])
|
||||
);
|
||||
};
|
||||
|
||||
export const isValidPoint = (point: unknown): point is LocalPoint => {
|
||||
return (
|
||||
Array.isArray(point) &&
|
||||
point.length === 2 &&
|
||||
isFiniteNumber(point[0]) &&
|
||||
isFiniteNumber(point[1])
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"@excalidraw/math": ["./math/src/index.ts"],
|
||||
"@excalidraw/math/*": ["./math/src/*"],
|
||||
"@excalidraw/utils": ["./utils/src/index.ts"],
|
||||
"@excalidraw/utils/*": ["./utils/src/*"]
|
||||
"@excalidraw/utils/*": ["./utils/src/*"],
|
||||
"@excalidraw/fractional-indexing": ["./fractional-indexing/src/index.ts"],
|
||||
"@excalidraw/fractional-indexing/*": ["./fractional-indexing/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"showHyperlinkPopup": false,
|
||||
"showWelcomeScreen": false,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"stats": {
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
|
||||
@@ -13,7 +13,12 @@ const getConfig = (outdir) => ({
|
||||
alias: {
|
||||
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
|
||||
},
|
||||
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
|
||||
external: [
|
||||
"@excalidraw/common",
|
||||
"@excalidraw/element",
|
||||
"@excalidraw/math",
|
||||
"@excalidraw/fractional-indexing",
|
||||
],
|
||||
});
|
||||
|
||||
function buildDev(config) {
|
||||
|
||||
@@ -74,7 +74,12 @@ const getConfig = (outdir) => ({
|
||||
alias: {
|
||||
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
|
||||
},
|
||||
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
|
||||
external: [
|
||||
"@excalidraw/common",
|
||||
"@excalidraw/element",
|
||||
"@excalidraw/math",
|
||||
"@excalidraw/fractional-indexing",
|
||||
],
|
||||
loader: {
|
||||
".woff2": "file",
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user