Compare commits

..

24 Commits

Author SHA1 Message Date
Ryan Di e98e2cccb8 lint 2026-04-28 21:21:55 +10:00
Ryan Di 8424d78254 fix(line-snap): prefer bindable targets over external snaps 2026-04-28 21:16:32 +10:00
Ryan Di 8763bebb59 perf(line-snap): reuse scene elements while snapping linear points 2026-04-28 21:07:11 +10:00
Ryan Di 483a225eac refactor(line-snap): rename axis snap helpers 2026-04-28 21:06:46 +10:00
Ryan Di 79e802d9ed perf(snapping): reduce line point snap scanning 2026-04-28 17:06:54 +10:00
Ryan Di 309849925d Revert "refactor(linear): split point snapping helpers"
This reverts commit 702e029755.
2026-04-28 16:22:49 +10:00
Ryan Di 971237c0df Revert "refactor(snapping): clarify linear point reference options"
This reverts commit 79beed3f5c.
2026-04-28 16:22:49 +10:00
Ryan Di 79beed3f5c refactor(snapping): clarify linear point reference options 2026-04-28 16:16:10 +10:00
Ryan Di 702e029755 refactor(linear): split point snapping helpers 2026-04-28 16:14:19 +10:00
Ryan Di 0bbaf34187 refactor(app): centralize line snapline state sync 2026-04-28 16:11:43 +10:00
Ryan Di 18febfeaf2 test(linear): cover line snapping interactions 2026-04-28 16:02:13 +10:00
Ryan Di 53557919dd Merge branch 'master' into ryan-di/line-snapping 2026-04-28 15:52:10 +10:00
Ryan Di 60a459b135 refactor: remove points function from snapping and move to linear editor 2025-08-04 18:04:59 +10:00
Ryan Di 7332e76d56 refactor: simplify code 2025-08-04 13:46:33 +10:00
Ryan Di dceaa53b0c fix: do not snap to pointer when creating 2025-08-04 12:33:49 +10:00
Ryan Di 6e968324fb fix snapshots 2025-08-04 12:09:06 +10:00
dwelle 09b18cacec Merge branch 'master' into ryan-di/line-snapping
# Conflicts:
#	packages/element/src/linearElementEditor.ts
#	packages/element/src/snapping.ts
#	packages/excalidraw/components/App.tsx
2025-07-31 22:42:52 +02:00
Ryan Di 0e197ef5c4 fix: do not snap to each other when moving multiple points together 2025-06-26 17:22:42 +10:00
Ryan Di a0f7edadec test: update snapshots 2025-06-24 21:02:48 +10:00
Ryan Di 58c9bb4712 merge: with master 2025-06-24 21:00:06 +10:00
Ryan Di d1c6304d42 test: update snapshots 2025-06-24 20:41:27 +10:00
Ryan Di c1a54455bb feat: add snapping on top of angle locking when both enabled 2025-06-24 18:37:07 +10:00
Ryan Di 07640dd756 feat: extend line snapping to creation 2025-06-16 20:55:27 +10:00
Ryan Di 5403fa8a0d feat: line snapping 2025-06-13 17:50:06 +10:00
114 changed files with 2396 additions and 5280 deletions
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v2
with:
node-version: 20.x
- name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- run: docker build -t excalidraw .
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v2
with:
node-version: 20.x
+2 -2
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v2
with:
node-version: 20.x
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v3
- name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with:
-86
View File
@@ -6,97 +6,11 @@ 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
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
sentry:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v2
with:
node-version: 20.x
- name: Install and build
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v3
with:
node-version: 20.x
- name: Install in packages/excalidraw
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v2
with:
node-version: "20.x"
- name: "Install Deps"
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install and test
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build
FROM --platform=${BUILDPLATFORM} node:24 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 --frozen-lockfile --network-timeout 600000
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
-2
View File
@@ -136,7 +136,6 @@ import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
ConsoleLogger,
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
@@ -1262,7 +1261,6 @@ const ExcalidrawWrapper = () => {
ref={debugCanvasRef}
/>
)}
{isVisualDebuggerEnabled() && <ConsoleLogger />}
</Excalidraw>
</div>
);
-1
View File
@@ -42,7 +42,6 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
LOCAL_STORAGE_DEBUG_CONSOLE: "excalidraw-debug-console",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
+1 -12
View File
@@ -355,8 +355,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
stopCollaboration = (keepRemoteState = true) => {
this.broadcastElements.flush();
this.broadcastElements.cancel();
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
@@ -943,9 +941,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.broadcastIdleChange(userState);
};
private _broadcastElements = (
elements: readonly OrderedExcalidrawElement[],
) => {
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@@ -956,13 +952,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
};
broadcastElements = throttle(
(elements: readonly OrderedExcalidrawElement[]) =>
this._broadcastElements(elements),
10,
{ leading: true, trailing: true },
);
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
+3 -32
View File
@@ -4,8 +4,7 @@ import {
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { MainMenu } from "@excalidraw/excalidraw/index";
import DropdownMenuItemCheckbox from "@excalidraw/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox";
import React, { useState } from "react";
import React from "react";
import { isDevEnv } from "@excalidraw/common";
@@ -14,29 +13,7 @@ import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import {
isVisualDebuggerEnabled,
loadConsoleLoggerState,
saveDebugState,
setConsoleLoggerEnabled,
} from "./DebugCanvas";
const ConsoleLoggerToggle = () => {
const [checked, setChecked] = useState(() => loadConsoleLoggerState());
return (
<DropdownMenuItemCheckbox
checked={checked}
onSelect={(event) => {
const next = !checked;
setChecked(next);
setConsoleLoggerEnabled(next);
event.preventDefault();
}}
>
Show console log overlay
</DropdownMenuItemCheckbox>
);
};
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -100,13 +77,7 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences
additionalItems={
isDevEnv() && isVisualDebuggerEnabled() ? (
<ConsoleLoggerToggle />
) : null
}
/>
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
+1 -208
View File
@@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import {
getGlobalFixedPointForBindableElement,
@@ -435,34 +435,6 @@ export const loadSavedDebugState = () => {
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const loadConsoleLoggerState = (): boolean => {
try {
const raw = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE);
if (raw !== null) {
return JSON.parse(raw) === true;
}
} catch {}
return false;
};
export const saveConsoleLoggerState = (enabled: boolean) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE,
JSON.stringify(enabled),
);
} catch {}
};
const CONSOLE_LOGGER_TOGGLE_EVENT = "excalidraw-debug-console-toggle";
export const setConsoleLoggerEnabled = (enabled: boolean) => {
saveConsoleLoggerState(enabled);
window.dispatchEvent(
new CustomEvent<boolean>(CONSOLE_LOGGER_TOGGLE_EVENT, { detail: enabled }),
);
};
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
@@ -487,7 +459,6 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
_clearLogsCallback?.();
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
@@ -495,7 +466,6 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
_clearLogsCallback?.();
onChange();
}, [onChange]);
@@ -593,181 +563,4 @@ const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
},
);
type LogLevel = "log" | "info" | "warn" | "error";
interface LogEntry {
id: number;
level: LogLevel;
message: string;
timestamp: number;
}
const LOG_COLORS: Record<LogLevel, string> = {
log: "rgba(220,220,220,0.9)",
info: "rgba(100,180,255,0.9)",
warn: "rgba(255,200,60,0.9)",
error: "rgba(255,90,90,0.9)",
};
const MAX_LOGS = 500;
let logIdCounter = 0;
let _clearLogsCallback: (() => void) | null = null;
export const ConsoleLogger = () => {
const [enabled, setEnabled] = useState(() => loadConsoleLoggerState());
const [logs, setLogs] = useState<LogEntry[]>([]);
const logsRef = useRef<LogEntry[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{ startY: number; startScrollTop: number } | null>(
null,
);
const isDragging = useRef(false);
useEffect(() => {
const handler = (e: Event) => {
setEnabled((e as CustomEvent<boolean>).detail);
};
window.addEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler);
return () => {
window.removeEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler);
};
}, []);
useEffect(() => {
_clearLogsCallback = () => {
logsRef.current = [];
setLogs([]);
};
return () => {
_clearLogsCallback = null;
};
}, []);
useEffect(() => {
const originals: Record<LogLevel, (...args: unknown[]) => void> = {
// eslint-disable-next-line no-console
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
const patch = (level: LogLevel) => {
// eslint-disable-next-line no-console
console[level] = (...args: unknown[]) => {
originals[level](...args);
const message = args
.map((a) =>
typeof a === "object" ? JSON.stringify(a, null, 0) : String(a),
)
.join(" ");
const entry: LogEntry = {
id: ++logIdCounter,
level,
message,
timestamp: Date.now(),
};
logsRef.current = [...logsRef.current, entry].slice(-MAX_LOGS);
setLogs([...logsRef.current]);
if (!isDragging.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
};
(["log", "info", "warn", "error"] as LogLevel[]).forEach(patch);
return () => {
(["log", "info", "warn", "error"] as LogLevel[]).forEach((level) => {
// eslint-disable-next-line no-console
console[level] = originals[level];
});
};
}, []);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!scrollRef.current) {
return;
}
dragState.current = {
startY: e.clientY,
startScrollTop: scrollRef.current.scrollTop,
};
scrollRef.current.setPointerCapture(e.pointerId);
isDragging.current = true;
e.preventDefault();
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragState.current || !scrollRef.current) {
return;
}
const delta = dragState.current.startY - e.clientY;
scrollRef.current.scrollTop = dragState.current.startScrollTop + delta;
e.preventDefault();
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
isDragging.current = false;
}, []);
if (!enabled || logs.length === 0) {
return null;
}
return (
<div
ref={scrollRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{
position: "fixed",
top: 8,
right: 8,
zIndex: 9999,
maxWidth: 420,
maxHeight: "60vh",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pointerEvents: "all",
cursor: "grab",
userSelect: "none",
scrollbarWidth: "none",
}}
>
{logs.map((entry) => (
<div
key={entry.id}
style={{
background: "rgba(18,18,20,0.55)",
backdropFilter: "blur(8px) saturate(1.4)",
WebkitBackdropFilter: "blur(8px) saturate(1.4)",
borderLeft: `3px solid ${LOG_COLORS[entry.level]}`,
borderRadius: 4,
padding: "2px 8px",
fontFamily: "monospace",
fontSize: 11,
lineHeight: 1.5,
color: LOG_COLORS[entry.level],
wordBreak: "break-all",
whiteSpace: "pre-wrap",
opacity: 0.95,
boxShadow: "0 1px 4px rgba(0,0,0,0.35)",
}}
>
<span style={{ opacity: 0.5, marginRight: 6 }}>
{entry.level.toUpperCase()}
</span>
{entry.message}
</div>
))}
</div>
);
};
export default DebugCanvas;
+1 -1
View File
@@ -26,6 +26,7 @@ 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";
@@ -38,7 +39,6 @@ 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";
-7
View File
@@ -75,13 +75,6 @@ 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: {
+1 -2
View File
@@ -56,8 +56,7 @@
"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: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:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
+1 -5
View File
@@ -80,11 +80,7 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string, enable = true): string => {
if (!enable) {
return color;
}
export const applyDarkModeFilter = (color: string): string => {
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
+3 -4
View File
@@ -337,10 +337,9 @@ 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_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024,
};
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
+1 -2
View File
@@ -64,7 +64,6 @@
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
"@excalidraw/math": "0.18.0"
}
}
+36 -12
View File
@@ -338,18 +338,27 @@ export class Scene {
this.callbacks.clear();
}
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
if (index === null) {
index = this.elements.length;
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
}
if (!Number.isFinite(index) || index < 0) {
@@ -369,9 +378,24 @@ export class Scene {
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
this.insertElementsAtIndex([element], null);
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);
};
getElementIndex(elementId: string) {
+6 -5
View File
@@ -734,11 +734,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
});
// Handle outside-outside binding to the same element
if (
otherBinding &&
otherBinding.elementId === hit?.id &&
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin)
) {
if (otherBinding && otherBinding.elementId === hit?.id) {
invariant(
!opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
"appState.selectedLinearElement.initialState.origin must be defined for new arrows",
);
return {
start: {
mode: "inside",
+2 -7
View File
@@ -61,8 +61,6 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -85,7 +83,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
const isDraggableFromInside =
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
!isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
@@ -326,10 +324,7 @@ export const getAllHoveredElementAtPoint = (
) {
candidateElements.push(element);
if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
if (!isTransparent(element.backgroundColor)) {
break;
}
}
+2 -22
View File
@@ -111,9 +111,6 @@ export const duplicateElements = (
* user interaction.
*/
type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
}
| {
/**
@@ -173,8 +170,6 @@ 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") {
@@ -255,9 +250,6 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
@@ -282,7 +274,7 @@ export const duplicateElements = (
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element) && !preserveFrameChildrenOrder
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@@ -298,25 +290,13 @@ export const duplicateElements = (
// frame duplication
// -------------------------------------------------------------------------
if (
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
if (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) => {
+2 -12
View File
@@ -1,9 +1,6 @@
import { arrayToMap } from "@excalidraw/common";
import { generateNKeysBetween } from "fractional-indexing";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
@@ -385,13 +382,6 @@ const isValidFractionalIndex = (
return false;
}
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
+39 -97
View File
@@ -19,11 +19,9 @@ import {
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
@@ -103,9 +101,8 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return boundsContainBounds(
getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
);
};
@@ -492,44 +489,10 @@ 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;
};
/**
* 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).
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
*
* @returns mutated allElements (same data structure)
*/
@@ -537,11 +500,19 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
const commonFrameId = getCommonFrameId(elementsToAdd);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const finalElementsToAdd = new Set<ExcalidrawElement>();
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -552,8 +523,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
// - add bound text elements if not already in the array
// - keep elements already in the frame so mixed selections can be reordered
// together
// - filter out elements that are already in the frame
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
@@ -566,68 +536,38 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue;
}
if (element.frameId && element.frameId !== frame.id) {
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
finalElementsToAdd.add(element);
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
finalElementsToAdd.add(boundTextElement);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
}
}
for (const element of finalElementsToAdd) {
// 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,
});
}
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
// (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;
return allElements;
};
export const removeElementsFromFrame = (
@@ -681,11 +621,13 @@ 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();
};
+1 -1
View File
@@ -81,7 +81,6 @@ export * from "./mutateElement";
export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement";
export { invalidateFreeDrawIncrementalCanvas } from "./renderFreedraw";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
@@ -89,6 +88,7 @@ export * from "./selection";
export * from "./shape";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./snapping";
export * from "./sortElements";
export * from "./store";
export * from "./textElement";
+356 -97
View File
@@ -7,6 +7,7 @@ import {
type LocalPoint,
pointDistance,
vectorFromPoint,
line,
curveLength,
curvePointAtLength,
} from "@excalidraw/math";
@@ -29,6 +30,9 @@ import {
isPathALoop,
moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
snapLinearElementPoint,
snapToDiscreteAngle,
type SnapLine,
type Store,
} from "@excalidraw/element";
@@ -48,6 +52,7 @@ import {
calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled,
maxBindingDistance_simple,
snapToMid,
updateBoundPoint,
} from "./binding";
@@ -56,6 +61,7 @@ import {
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement";
@@ -294,7 +300,10 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
): Pick<
AppState,
"suggestedBinding" | "selectedLinearElement" | "snapLines"
> | null {
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const { elementId } = linearElementEditor;
@@ -311,36 +320,26 @@ export class LinearElementEditor {
linearElementEditor.customLineAngle ??
determineCustomLinearAngle(pivotPoint, element.points[idx]);
// Determine if point movement should happen and how much
let deltaX = 0;
let deltaY = 0;
if (shouldRotateWithDiscreteAngle(event)) {
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
const { point: newDraggingPointPosition, snapLines } =
LinearElementEditor._getSnappedPointForLinearElement({
app,
event,
elements,
elementsMap,
pivotPoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
element,
pointIndex: idx,
scenePointerX,
scenePointerY,
pointerOffset: linearElementEditor.pointerOffset,
referencePoint: shouldRotateWithDiscreteAngle(event)
? pivotPoint
: null,
selectedPointsIndices: [idx],
customLineAngle,
);
const target = pointFrom<LocalPoint>(
width + pivotPoint[0],
height + pivotPoint[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];
}
const deltaX = newDraggingPointPosition[0] - point[0];
const deltaY = newDraggingPointPosition[1] - point[1];
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
@@ -398,6 +397,8 @@ export class LinearElementEditor {
// 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))
@@ -436,6 +437,7 @@ export class LinearElementEditor {
return {
selectedLinearElement: newLinearElementEditor,
suggestedBinding,
snapLines,
};
}
@@ -445,7 +447,10 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
): Pick<
AppState,
"suggestedBinding" | "selectedLinearElement" | "snapLines"
> | null {
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const { elbowed, elementId, initialState } = linearElementEditor;
@@ -486,14 +491,13 @@ export class LinearElementEditor {
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
}) lastClickedPoint(${lastClickedPoint})`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
// The adjacent point to the one dragged point
const pivotPoint =
@@ -507,35 +511,27 @@ export class LinearElementEditor {
element.points.length - 1,
);
// 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,
const { point: newDraggingPointPosition, snapLines } =
LinearElementEditor._getSnappedPointForLinearElement({
app,
event,
elements,
elementsMap,
pivotPoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
element,
pointIndex: lastClickedPoint,
scenePointerX,
scenePointerY,
pointerOffset: linearElementEditor.pointerOffset,
referencePoint:
shouldRotateWithDiscreteAngle(event) && singlePointDragged
? pivotPoint
: null,
selectedPointsIndices,
customLineAngle,
);
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];
}
});
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
@@ -674,6 +670,7 @@ export class LinearElementEditor {
return {
selectedLinearElement: newLinearElementEditor,
suggestedBinding,
snapLines,
};
}
@@ -1178,7 +1175,10 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
app: AppClassProperties,
): LinearElementEditor | null {
): {
editingLinearElement: LinearElementEditor;
snapLines: readonly SnapLine[];
} | null {
const appState = app.state;
if (!appState.selectedLinearElement?.isEditing) {
return null;
@@ -1187,7 +1187,10 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.selectedLinearElement;
return {
editingLinearElement: appState.selectedLinearElement,
snapLines: appState.snapLines,
};
}
const { points } = element;
@@ -1199,36 +1202,37 @@ export class LinearElementEditor {
}
return appState.selectedLinearElement?.lastUncommittedPoint
? {
...appState.selectedLinearElement,
lastUncommittedPoint: null,
editingLinearElement: {
...appState.selectedLinearElement,
lastUncommittedPoint: null,
},
snapLines: [],
}
: appState.selectedLinearElement;
: {
editingLinearElement: appState.selectedLinearElement,
snapLines: [],
};
}
let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const anchor = points[points.length - 2];
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
const anchor = points[points.length - 2];
const elements = app.scene.getNonDeletedElements();
const { point: newPoint, snapLines } =
LinearElementEditor._getSnappedPointForLinearElement({
app,
event,
elements,
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(),
);
}
pointIndex: points.length - 1,
scenePointerX,
scenePointerY,
pointerOffset: appState.selectedLinearElement.pointerOffset,
referencePoint:
shouldRotateWithDiscreteAngle(event) && points.length >= 2
? anchor
: null,
selectedPointsIndices: [points.length - 1],
});
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(
@@ -1236,7 +1240,7 @@ export class LinearElementEditor {
app.scene,
new Map([
[
element.points.length - 1,
points.length - 1,
{
point: newPoint,
},
@@ -1246,9 +1250,13 @@ export class LinearElementEditor {
} else {
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
editingLinearElement: {
...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
},
snapLines,
};
}
@@ -1274,18 +1282,53 @@ 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;
return element.points.map((p) => {
const { x, y } = element;
return pointRotateRads(
pointFrom(x + p[0], y + p[1]),
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),
pointFrom(cx, cy),
element.angle,
);
});
globalPoints.push(rotated);
}
return globalPoints;
}
static getPointAtIndexGlobalCoordinates(
@@ -1839,6 +1882,222 @@ 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,
@@ -2139,13 +2398,13 @@ const pointDraggingUpdates = (
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
// NOTE: Avoid stale point index issue potentially caused by elbow
// arrows unpredictably changing the number of points during dragging
const point = element.points[pointIndex] ?? element.points.at(-1);
return [
pointIndex,
{
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
isDragging: true,
},
];
+32 -82
View File
@@ -65,12 +65,8 @@ import {
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import {
drawFreeDrawSegments,
generateOrUpdateFreeDrawIncrementalCanvas,
getFreedrawCanvasPadding,
} from "./renderFreedraw";
import type {
ExcalidrawElement,
@@ -96,7 +92,7 @@ const isPendingImageElement = (
const getCanvasPadding = (element: ExcalidrawElement) => {
switch (element.type) {
case "freedraw":
return getFreedrawCanvasPadding(element);
return element.strokeWidth * 12;
case "text":
return element.fontSize / 2;
case "arrow":
@@ -148,15 +144,6 @@ export interface ExcalidrawElementWithCanvas {
imageCrop: ExcalidrawImageElement["crop"] | null;
containingFrameOpacity: number;
boundTextCanvas: HTMLCanvasElement;
canvasOriginSceneX?: number;
canvasOriginSceneY?: number;
/**
* Tip canvas for incremental freedraw rendering. Contains only the last
* unfinalised segment (whose Catmull-Rom right-hand tangent changes with
* each new point) and is cleared + redrawn every frame. Composited on top
* of `canvas` (the committed accumulation canvas) in drawElementFromCanvas.
*/
tipCanvas?: HTMLCanvasElement;
}
const cappedElementCanvasSize = (
@@ -266,7 +253,7 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context, renderConfig, scale);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
@@ -402,7 +389,6 @@ const drawElementOnCanvas = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
scale = 1,
) => {
switch (element.type) {
case "rectangle":
@@ -429,8 +415,23 @@ const drawElementOnCanvas = (
break;
}
case "freedraw": {
// Draw directly to canvas
context.save();
drawFreeDrawSegments(element, context, renderConfig, 0, undefined, scale);
const shapes = ShapeCache.generateElementShape(element, renderConfig);
for (const shape of shapes) {
if (typeof shape === "string") {
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
}
}
context.restore();
break;
}
@@ -554,10 +555,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
@@ -615,24 +616,6 @@ const generateElementWithCanvas = (
: {
value: 1 as NormalizedZoomValue,
};
// Incremental rendering path for freedraw elements being actively drawn.
// ShapeCache.delete() clears elementWithCanvasCache on every added point, so
// we bypass that cache entirely and use freedrawIncrementalCache instead.
if (
isFreeDrawElement(element) &&
"newElement" in appState &&
appState.newElement?.id === element.id
) {
return generateOrUpdateFreeDrawIncrementalCanvas(
element as ExcalidrawFreeDrawElement,
elementsMap,
zoom,
renderConfig,
appState,
);
}
const prevElementWithCanvas = elementWithCanvasCache.get(element);
const shouldRegenerateBecauseZoom =
prevElementWithCanvas &&
@@ -735,39 +718,16 @@ const drawElementFromCanvas = (
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
// For the incremental freedraw path, the canvas origin is stored explicitly
// because the canvas is over-allocated beyond the tight element bounds.
const destX =
elementWithCanvas.canvasOriginSceneX !== undefined
? (elementWithCanvas.canvasOriginSceneX + appState.scrollX) *
window.devicePixelRatio
: (x1 + appState.scrollX) * window.devicePixelRatio - padding;
const destY =
elementWithCanvas.canvasOriginSceneY !== undefined
? (elementWithCanvas.canvasOriginSceneY + appState.scrollY) *
window.devicePixelRatio
: (y1 + appState.scrollY) * window.devicePixelRatio - padding;
context.drawImage(
elementWithCanvas.canvas!,
destX,
destY,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
);
// Composite the tip canvas (incremental freedraw path) on top. It is
// the same size and at the same scene origin as the committed canvas, so
// it uses identical destX / destY / dimensions.
if (elementWithCanvas.tipCanvas) {
context.drawImage(
elementWithCanvas.tipCanvas,
destX,
destY,
elementWithCanvas.tipCanvas.width / elementWithCanvas.scale,
elementWithCanvas.tipCanvas.height / elementWithCanvas.scale,
);
}
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
@@ -851,10 +811,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = applyDarkModeFilter(
FRAME_STYLE.strokeColor,
appState.theme === THEME.DARK,
);
context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
@@ -907,14 +867,6 @@ export const renderElement = (
return;
}
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if (
!appState?.shouldCacheIgnoreZoom &&
(!element.angle || isRightAngleRads(element.angle))
) {
context.imageSmoothingEnabled = false;
}
drawElementFromCanvas(
elementWithCanvas,
context,
@@ -922,8 +874,6 @@ export const renderElement = (
appState,
allElementsMap,
);
context.imageSmoothingEnabled = currentImageSmoothingStatus;
}
break;
@@ -1033,7 +983,7 @@ export const renderElement = (
}
context.restore();
// not exporting -> optimized rendering (cache & render from element
// not exporting optimized rendering (cache & render from element
// canvases)
} else {
const elementWithCanvas = generateElementWithCanvas(
-549
View File
@@ -1,549 +0,0 @@
import { applyDarkModeFilter, THEME } from "@excalidraw/common";
import type { StaticCanvasRenderConfig } from "@excalidraw/excalidraw/scene/types";
import type {
AppState,
InteractiveCanvasAppState,
StaticCanvasAppState,
Zoom,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords } from "./bounds";
import { getContainingFrame } from "./frame";
import type { ExcalidrawElementWithCanvas } from "./renderElement";
import type {
ExcalidrawFreeDrawElement,
NonDeletedSceneElementsMap,
} from "./types";
const DEFAULT_FREEDRAW_PRESSURE = 0.5;
/**
* Half-width (in samples) of the triangular smoothing kernel applied to raw
* pressure values before computing stroke radii. A radius of R means each
* pressure sample is averaged with R neighbours on each side, weighted
* linearly so the centre sample has weight R+1 and the outermost weight 1.
* Larger values produce a smoother, more uniform stroke width.
*/
const PRESSURE_SMOOTHING_RADIUS = 6;
/**
* Draws a single stroke segment primitive for the triplet (pPrev, pCur, pNext).
*
* The primitive is a closed quadrilateral with curved top and bottom edges:
* A = midpoint(pPrev, pCur) left junction, shared with the previous primitive
* B = midpoint(pCur, pNext) right junction, shared with the next primitive
* M'1/M'2 at A: ±rA perpendicular to the pPrevpCur direction
* M1/M2 at pCur: ±rCur along the bisector normal of the two edge directions
* M''1/M''2 at B: ±rB perpendicular to the pCurpNext direction
*
* Shape boundary (clockwise):
* M'1 →[quadratic Bezier through M1]→ M''1 →[line]→ M''2
* [quadratic Bezier through M2] M'2 →[line]→ M'1
*
* Adjacent primitives share their junction points so the stroke outline is
* geometrically continuous with no gaps or overlaps.
*/
const drawStrokeSegment = (
context: CanvasRenderingContext2D,
pPrevX: number,
pPrevY: number,
rPrev: number,
pCurX: number,
pCurY: number,
rCur: number,
pNextX: number,
pNextY: number,
rNext: number,
) => {
// A = midpoint(pPrev, pCur), B = midpoint(pCur, pNext)
const ax = (pPrevX + pCurX) * 0.5;
const ay = (pPrevY + pCurY) * 0.5;
const rA = (rPrev + rCur) * 0.5;
const bx = (pCurX + pNextX) * 0.5;
const by = (pCurY + pNextY) * 0.5;
const rB = (rCur + rNext) * 0.5;
// Perpendicular unit vector at A (normal to pPrev→pCur)
const daX = pCurX - pPrevX;
const daY = pCurY - pPrevY;
const daLenInv = 1 / (Math.sqrt(daX * daX + daY * daY) || 1e-10);
const nAX = -daY * daLenInv;
const nAY = daX * daLenInv;
// Perpendicular unit vector at B (normal to pCur→pNext)
const dbX = pNextX - pCurX;
const dbY = pNextY - pCurY;
const dbLenInv = 1 / (Math.sqrt(dbX * dbX + dbY * dbY) || 1e-10);
const nBX = -dbY * dbLenInv;
const nBY = dbX * dbLenInv;
// Bisector normal at pCur: normalised average of nA and nB
const bisRawX = nAX + nBX;
const bisRawY = nAY + nBY;
const bisLen = Math.sqrt(bisRawX * bisRawX + bisRawY * bisRawY);
const bisNX = bisLen > 1e-10 ? bisRawX / bisLen : nAX;
const bisNY = bisLen > 1e-10 ? bisRawY / bisLen : nAY;
// M'1, M'2 at A
const mp1x = ax + nAX * rA;
const mp1y = ay + nAY * rA;
const mp2x = ax - nAX * rA;
const mp2y = ay - nAY * rA;
// M1, M2 at pCur — used directly as the quadratic Bézier control points.
// The junction points (M'1, M''1, etc.) are midpoints between consecutive
// control points, which is the classic midpoint quadratic B-spline scheme.
// This guarantees C1 continuity: the shared junction is always the midpoint
// of the two flanking CPs, so the tangent is continuous across segments.
const m1x = pCurX + bisNX * rCur;
const m1y = pCurY + bisNY * rCur;
const m2x = pCurX - bisNX * rCur;
const m2y = pCurY - bisNY * rCur;
// M''1, M''2 at B
const mpp1x = bx + nBX * rB;
const mpp1y = by + nBY * rB;
const mpp2x = bx - nBX * rB;
const mpp2y = by - nBY * rB;
context.beginPath();
context.moveTo(mp1x, mp1y);
// Top edge: M'1 → M''1, control point = M1 (bisector offset at pCur)
context.quadraticCurveTo(m1x, m1y, mpp1x, mpp1y);
// Right cap: M''1 → M''2
context.lineTo(mpp2x, mpp2y);
// Bottom edge: M''2 → M'2, control point = M2
context.quadraticCurveTo(m2x, m2y, mp2x, mp2y);
// Left cap: M'2 → M'1
context.closePath();
context.fill();
// Filled circles at the junction midpoints seal any sub-pixel anti-aliasing
// gap where adjacent segment fills share a boundary edge.
context.beginPath();
context.arc(ax, ay, rA, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(bx, by, rB, 0, Math.PI * 2);
context.fill();
};
/**
* Draws freedraw points as pressure-aware curved stroke segment primitives.
* For each consecutive triplet of points (i-1, i, i+1) a curved quadrilateral
* is drawn whose side edges sit at the midpoints of the consecutive point pairs
* and whose top/bottom edges are quadratic Bezier curves passing through the
* stroke-width offset at the centre point. Adjacent primitives share their
* side-edge positions, so the rendered outline is continuous with no gaps.
*
* @param fromIndex Draw segments starting from this point index (inclusive).
* Pass 0 to draw from the beginning.
* @param upToIndex Draw segments only up to (but not including) this point
* index. Omit or pass `undefined` to draw all remaining
* points. Used by the incremental canvas to stop short of
* the last segment so the committed canvas only contains
* segments whose geometry is fully determined by immutable
* points.
*/
export const drawFreeDrawSegments = (
element: ExcalidrawFreeDrawElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
fromIndex: number,
upToIndex?: number,
scale = 1,
) => {
const { points, pressures } = element;
const N = points.length;
const strokeColor =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fillStyle = applyDarkModeFilter(
strokeColor,
renderConfig.theme === THEME.DARK,
);
const baseRadius = (element.strokeWidth * 1.25) / 2;
// Causal (one-sided) triangular-kernel weighted average of past pressure
// samples. Only looks backward [i-R .. i], so a newly-arrived point never
// retroactively changes the smoothed pressure of any previously rendered
// segment. This ensures live and final renders are identical at all points.
// When simulatePressure is true, constant pressure is used for all points.
const getSmoothedPressure = (i: number): number => {
if (element.simulatePressure || pressures.length === 0) {
return DEFAULT_FREEDRAW_PRESSURE;
}
let sum = 0;
let totalWeight = 0;
for (let k = -PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
const idx = i + k;
if (idx < 0) {
continue;
}
const p =
idx < pressures.length ? pressures[idx] : DEFAULT_FREEDRAW_PRESSURE;
const w = PRESSURE_SMOOTHING_RADIUS + 1 + k; // 1 at i-R, R+1 at i
sum += p * w;
totalWeight += w;
}
return totalWeight > 0 ? sum / totalWeight : DEFAULT_FREEDRAW_PRESSURE;
};
if (
fromIndex === 0 &&
N === 1 &&
(upToIndex === undefined || upToIndex >= 1)
) {
// Single-point stroke -> filled circle (dot)
const r = baseRadius * getSmoothedPressure(0) * 2;
context.beginPath();
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
context.fill();
return;
}
const end = upToIndex !== undefined ? Math.min(upToIndex, N) : N;
const start = Math.max(fromIndex, 1);
for (let i = start; i < end; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
const r1 = baseRadius * getSmoothedPressure(i) * 2;
// Triplet: need i+1; if at the last point, mirror i-1 around i (degenerate tip).
let p2x: number;
let p2y: number;
let r2: number;
if (i < N - 1) {
p2x = points[i + 1][0];
p2y = points[i + 1][1];
r2 = baseRadius * getSmoothedPressure(i + 1) * 2;
} else {
p2x = 2 * p1[0] - p0[0];
p2y = 2 * p1[1] - p0[1];
r2 = r0;
}
drawStrokeSegment(
context,
p0[0],
p0[1],
r0,
p1[0],
p1[1],
r1,
p2x,
p2y,
r2,
);
}
};
// ─── Incremental freedraw canvas cache ───────────────────────────────────────
// A separate WeakMap that survives ShapeCache.delete() calls so that the raster
// accumulates new capsule segments without full regeneration on every added
// point.
// screen pixels - minimum extra lookahead space on each side
// (divided by scale at use)
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
// allocate current_dimension * factor extra on each side
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5;
interface FreeDrawIncrementalCanvas {
/**
* Accumulation canvas - contains all segments whose Catmull-Rom tangents are
* fully finalised (right-hand neighbour is known). With N points the last
* finalised segment ends at index `committedPointCount - 1`, meaning segment
* `[committedPointCount-2 -> committedPointCount-1]` has been drawn with the
* correct tangent at `committedPointCount-1` (since point
* `committedPointCount` existed when it was drawn). Never cleared; only
* appended to (or copied when bounds grow).
*/
committedCanvas: HTMLCanvasElement;
/**
* Tip canvas - same pixel dimensions and scene origin as `committedCanvas`.
* Cleared and redrawn every frame to contain only the last segment
* `[committedPointCount-1 -> N-1]` whose tangent at `N-1` is still
* provisional (no right-hand neighbour yet). Composited on top of
* `committedCanvas` at display time.
*/
tipCanvas: HTMLCanvasElement;
/**
* Number of points that have been permanently drawn on `committedCanvas`.
* The committed canvas contains segments through point index
* `committedPointCount - 1` with final tangents. Always lags the current
* point count by 1 (the tip holds the last unfinalisable segment).
*/
committedPointCount: number;
canvasOriginSceneX: number;
canvasOriginSceneY: number;
canvasAllocX1: number;
canvasAllocY1: number;
canvasAllocX2: number;
canvasAllocY2: number;
scale: number;
theme: AppState["theme"];
}
const freedrawIncrementalCache = new WeakMap<
ExcalidrawFreeDrawElement,
FreeDrawIncrementalCanvas
>();
export const getFreedrawCanvasPadding = (element: ExcalidrawFreeDrawElement) =>
element.strokeWidth * 12;
/**
* Generates or incrementally updates the two-canvas (committed + tip) raster
* for a freedraw element being actively drawn.
*
* ## Two-canvas split
*
* A Catmull-Rom tangent at point `i` depends on `points[i+1]`. Until
* `points[i+1]` arrives, the tangent at `i` uses a mirrored fallback and is
* therefore provisional. The segment ending at the current tip `[N-2 -> N-1]`
* is the only one with a provisional tangent.
*
* - **`committedCanvas`** - contains all segments whose tangents are final.
* With N points: segments `[0->1, ..., N-3->N-2]` (`committedPointCount =
* N-1`). This canvas is append-only; its pixels are never invalidated.
* When a new point `N` arrives, the segment `[N-2 -> N-1]` is now
* finalised (tangent at `N-1` uses `N` as the right-hand neighbour) and is
* drawn onto the committed canvas. `committedPointCount` advances to `N`.
*
* - **`tipCanvas`** - cleared and redrawn every frame to contain only the
* last provisional segment `[committedPointCount-1 -> N-1]`. Composited on
* top of `committedCanvas` at display time.
*/
export const generateOrUpdateFreeDrawIncrementalCanvas = (
element: ExcalidrawFreeDrawElement,
elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
): ExcalidrawElementWithCanvas | null => {
const scale = zoom.value;
const dpr = window.devicePixelRatio;
const padding = getFreedrawCanvasPadding(element);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const containingFrameOpacity =
getContainingFrame(element, elementsMap)?.opacity || 100;
const N = element.points.length;
const prevInc = freedrawIncrementalCache.get(element);
const boundsExceeded =
prevInc !== undefined &&
(x1 < prevInc.canvasAllocX1 ||
y1 < prevInc.canvasAllocY1 ||
x2 > prevInc.canvasAllocX2 ||
y2 > prevInc.canvasAllocY2);
const needsAlloc =
prevInc === undefined ||
boundsExceeded ||
prevInc.scale !== scale ||
prevInc.theme !== appState.theme;
// ── Canvas allocation / reallocation ──────────────────────────────────────
let committedCanvas: HTMLCanvasElement;
let tipCanvas: HTMLCanvasElement;
let canvasOriginSceneX: number;
let canvasOriginSceneY: number;
let canvasScale: number;
// How many points to start the committed-canvas update from. On a full
// regen this is 0; on a bounds-exceeded realloc it is the existing committed
// count so we only append the new segments.
let committedFromIndex: number;
if (needsAlloc) {
// Over-allocate proportionally to the current bounding box so fast large
// strokes trigger far fewer reallocations.
const overshootX = Math.max(
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
);
const overshootY = Math.max(
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
(y2 - y1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
);
const allocX1 = x1 - overshootX;
const allocY1 = y1 - overshootY;
const allocX2 = x2 + overshootX;
const allocY2 = y2 + overshootY;
canvasOriginSceneX = allocX1 - padding / dpr;
canvasOriginSceneY = allocY1 - padding / dpr;
const rawW = (allocX2 - allocX1) * dpr + padding * 2;
const rawH = (allocY2 - allocY1) * dpr + padding * 2;
// Respect browser canvas size limits.
const AREA_LIMIT = 16777216;
const WIDTH_HEIGHT_LIMIT = 32767;
canvasScale = scale;
if (
rawW * canvasScale > WIDTH_HEIGHT_LIMIT ||
rawH * canvasScale > WIDTH_HEIGHT_LIMIT
) {
canvasScale = Math.min(
WIDTH_HEIGHT_LIMIT / rawW,
WIDTH_HEIGHT_LIMIT / rawH,
);
}
if (rawW * rawH * canvasScale * canvasScale > AREA_LIMIT) {
canvasScale = Math.sqrt(AREA_LIMIT / (rawW * rawH));
}
const canvasWidth = Math.floor(rawW * canvasScale);
const canvasHeight = Math.floor(rawH * canvasScale);
if (!canvasWidth || !canvasHeight) {
return null;
}
committedCanvas = document.createElement("canvas");
committedCanvas.width = canvasWidth;
committedCanvas.height = canvasHeight;
tipCanvas = document.createElement("canvas");
tipCanvas.width = canvasWidth;
tipCanvas.height = canvasHeight;
if (
prevInc !== undefined &&
boundsExceeded &&
prevInc.scale === canvasScale &&
prevInc.theme === appState.theme
) {
// Bounds grew: copy committed raster to new canvas at the correct offset
// and keep accumulating. Tip will be redrawn below.
const copyX =
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
const copyY =
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
committedCanvas
.getContext("2d")!
.drawImage(prevInc.committedCanvas, copyX, copyY);
committedFromIndex = prevInc.committedPointCount;
} else {
// Full regeneration: zoom/theme change or first frame.
committedFromIndex = 0;
}
freedrawIncrementalCache.set(element, {
committedCanvas,
tipCanvas,
committedPointCount: committedFromIndex,
canvasOriginSceneX,
canvasOriginSceneY,
canvasAllocX1: allocX1,
canvasAllocY1: allocY1,
canvasAllocX2: allocX2,
canvasAllocY2: allocY2,
scale: canvasScale,
theme: appState.theme,
});
} else {
committedCanvas = prevInc.committedCanvas;
tipCanvas = prevInc.tipCanvas;
canvasOriginSceneX = prevInc.canvasOriginSceneX;
canvasOriginSceneY = prevInc.canvasOriginSceneY;
canvasScale = prevInc.scale;
committedFromIndex = prevInc.committedPointCount;
}
const inc = freedrawIncrementalCache.get(element)!;
// ── Helper: draw onto a canvas with the element's scene->pixel transform ──
const withElementContext = (
target: HTMLCanvasElement,
fn: (ctx: CanvasRenderingContext2D) => void,
) => {
const ctx = target.getContext("2d")!;
const offsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale;
const offsetY = (element.y - canvasOriginSceneY) * dpr * canvasScale;
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(dpr * canvasScale, dpr * canvasScale);
fn(ctx);
ctx.restore();
};
// ── Update committed canvas ───────────────────────────────────────────────
// With N points the last finalisable segment ends at N-2 (needs N-1 as
// right-hand neighbour for the tangent at N-2, and N-1 is always present).
// We draw from `committedFromIndex` up to (but not including) point N-1,
// so the committed canvas contains segments [0->1, ..., N-3->N-2].
const newCommittedCount = Math.max(1, N - 1);
if (committedFromIndex < newCommittedCount) {
withElementContext(committedCanvas, (ctx) => {
drawFreeDrawSegments(
element,
ctx,
renderConfig,
committedFromIndex,
newCommittedCount, // upToIndex - stop before the last provisional segment
canvasScale,
);
});
inc.committedPointCount = newCommittedCount;
}
// ── Redraw tip canvas ─────────────────────────────────────────────────────
// Always cleared and redrawn: contains the single provisional segment
// [committedPointCount-1 -> N-1] with a predicted-point ghost if available.
withElementContext(tipCanvas, (ctx) => {
ctx.clearRect(
-(element.x - canvasOriginSceneX),
-(element.y - canvasOriginSceneY),
tipCanvas.width / (dpr * canvasScale),
tipCanvas.height / (dpr * canvasScale),
);
drawFreeDrawSegments(
element,
ctx,
renderConfig,
inc.committedPointCount,
undefined, // draw to natural end (the tip segment)
canvasScale,
);
});
return {
element,
canvas: committedCanvas,
tipCanvas,
theme: appState.theme,
scale: canvasScale,
angle: element.angle,
zoomValue: zoom.value,
canvasOffsetX: 0,
canvasOffsetY: 0,
boundTextElementVersion: null,
imageCrop: null,
containingFrameOpacity,
boundTextCanvas: document.createElement("canvas"),
canvasOriginSceneX: inc.canvasOriginSceneX,
canvasOriginSceneY: inc.canvasOriginSceneY,
};
};
/**
* Removes the incremental freedraw canvas for the given element.
* Call this when a freedraw stroke is finalised so the next render
* produces a fresh tight-bounds canvas instead of the over-allocated one.
*/
export const invalidateFreeDrawIncrementalCanvas = (
element: ExcalidrawFreeDrawElement,
) => {
freedrawIncrementalCache.delete(element);
};
+19 -48
View File
@@ -34,6 +34,7 @@ import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
isElementIntersectingFrame,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
@@ -129,24 +130,13 @@ export const getElementsWithinSelection = (
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
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);
@@ -180,7 +170,7 @@ export const getElementsWithinSelection = (
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
isElementIntersectingFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
@@ -219,9 +209,10 @@ 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
@@ -230,7 +221,7 @@ export const getElementsWithinSelection = (
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
elementsInSelection.push(element);
continue;
}
@@ -320,7 +311,7 @@ export const getElementsWithinSelection = (
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
elementsInSelection.push(element);
continue;
}
}
@@ -329,41 +320,21 @@ export const getElementsWithinSelection = (
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection;
if (boxSelectionMode === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, 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);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
const group = groupId ? groups[groupId] : null;
return true;
});
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));
return elementsInSelection;
};
export const getVisibleAndNonSelectedElements = (
+72 -352
View File
@@ -1,4 +1,5 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import {
type GeometricShape,
@@ -27,12 +28,6 @@ import {
import { RoughGenerator } from "roughjs/bin/generator";
import type {
ElementShape,
ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
@@ -41,6 +36,11 @@ import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement";
@@ -218,7 +218,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
@@ -232,7 +234,9 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
}
@@ -245,7 +249,9 @@ export const generateRoughOptions = (
options.fill =
element.backgroundColor === "transparent"
? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
}
return options;
}
@@ -380,11 +386,12 @@ const getArrowheadShapes = (
return [];
}
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
const backgroundFillColor = applyDarkModeFilter(
canvasBackgroundColor,
isDarkMode,
);
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
@@ -974,7 +981,7 @@ const _generateElementShape = (
}
// (2) stroke
shapes.push(...getFreeDrawCapsulePaths(element));
shapes.push(getFreeDrawSvgPath(element));
return shapes;
}
@@ -1164,351 +1171,64 @@ export const toggleLinePolygonState = (
// freedraw shape helper
// -----------------------------------------------------------------------------
const FREEDRAW_DEFAULT_PRESSURE = 0.5;
const FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING = 3;
const FREEDRAW_PRESSURE_SMOOTHING_RADIUS = 6;
// Round to 2 dp — sub-pixel accuracy at SVG 96 dpi
const r2 = (v: number) => Math.round(v * 100) / 100;
/**
* SVG path `d` string for a single tapered capsule. Uses clockwise arcs
* (sweep=1) so the geometry matches the canvas 2D
* `arc(..., anticlockwise=false)` calls.
*/
const freedrawTaperedCapsulePath = (
x0: number,
y0: number,
r0: number,
x1: number,
y1: number,
r1: number,
): string => {
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.sqrt(dx * dx + dy * dy);
const r = Math.max(r0, r1);
if (len < r / 2) {
// Degenerate — full circle at midpoint via two clockwise 180° arcs.
const cx = r2((x0 + x1) / 2);
const cy = r2((y0 + y1) / 2);
const rr = r2(r);
return (
`M ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} ` +
`A ${rr} ${rr} 0 1 1 ${(cx + rr).toFixed(2)} ${cy.toFixed(2)} ` +
`A ${rr} ${rr} 0 1 1 ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} Z`
);
}
const px = -dy / len; // perpendicular unit x
const py = dx / len; // perpendicular unit y
// P0 +/- perp·r0 (start / back cap tangent points)
const b0x = r2(x0 + px * r0);
const b0y = r2(y0 + py * r0);
const b1x = r2(x0 - px * r0);
const b1y = r2(y0 - py * r0);
// P1 +/- perp·r1 (end / front cap tangent points)
const f0x = r2(x1 - px * r1);
const f0y = r2(y1 - py * r1);
const f1x = r2(x1 + px * r1);
const f1y = r2(y1 + py * r1);
const rr0 = r2(r0);
const rr1 = r2(r1);
// Back cap: clockwise 180° arc from (b0) to (b1) around P0.
// Front cap: clockwise 180° arc from (f0) to (f1) around P1.
return (
`M ${b0x.toFixed(2)} ${b0y.toFixed(2)} ` +
`A ${rr0.toFixed(2)} ${rr0.toFixed(2)} 0 1 1 ${b1x.toFixed(
2,
)} ${b1y.toFixed(2)} ` +
`L ${f0x.toFixed(2)} ${f0y.toFixed(2)} ` +
`A ${rr1.toFixed(2)} ${rr1.toFixed(2)} 0 1 1 ${f1x.toFixed(
2,
)} ${f1y.toFixed(2)} Z`
);
// NOTE not cached (-> for SVG export)
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
return getSvgPathFromStroke(
getFreedrawOutlinePoints(element),
) as SVGPathString;
};
/**
* Catmull-Rom tangent at points[i].Identical math to `getCatmullRomTangent`
* in renderElement.ts (predictedPoint is not needed for finalised strokes).
*/
const freedrawCatmullRomTangent = (
points: readonly (readonly [number, number])[],
i: number,
): [number, number] => {
const N = points.length;
const cur = points[i];
let next: readonly [number, number];
if (i < N - 1) {
next = points[i + 1];
} else {
const prev2 = i > 0 ? points[i - 1] : cur;
next = [2 * cur[0] - prev2[0], 2 * cur[1] - prev2[1]];
}
let tx: number;
let ty: number;
if (i === 0) {
tx = (next[0] - cur[0]) * 0.5;
ty = (next[1] - cur[1]) * 0.5;
} else {
const prev = points[i - 1];
tx = (next[0] - prev[0]) * 0.5;
ty = (next[1] - prev[1]) * 0.5;
}
// Chord-length clamping (PCHIP-style).
const magSq = tx * tx + ty * ty;
if (magSq > 0) {
const dNx = next[0] - cur[0];
const dNy = next[1] - cur[1];
const chordNext = Math.sqrt(dNx * dNx + dNy * dNy);
let chordPrev = chordNext;
if (i > 0) {
const prev = points[i - 1];
const dPx = cur[0] - prev[0];
const dPy = cur[1] - prev[1];
chordPrev = Math.sqrt(dPx * dPx + dPy * dPy);
}
const maxMag = 3 * Math.min(chordNext, chordPrev);
const mag = Math.sqrt(magSq);
if (mag > maxMag) {
const s = maxMag / mag;
tx *= s;
ty *= s;
}
}
return [tx, ty];
};
/**
* Triangular-kernel causal weighted pressure average (backward-only window).
* When `simulatePressure` is true or pressures array is empty, returns the
* default constant pressure so the geometry mirrors constant-pressure rendering.
*/
const getFreeDrawSmoothedPressure = (
element: ExcalidrawFreeDrawElement,
i: number,
): number => {
const { pressures } = element;
if (element.simulatePressure || pressures.length === 0) {
return FREEDRAW_DEFAULT_PRESSURE;
}
let sum = 0;
let totalWeight = 0;
for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
const idx = i + k;
if (idx < 0) {
continue;
}
const p =
idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE;
const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k;
sum += p * w;
totalWeight += w;
}
return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE;
};
/**
* Returns one SVG path `d` string per tapered-capsule sub-segment for a
* freedraw element, using the same Catmull-Rom Bezier subdivision and pressure
* smoothing as the canvas renderer.
*/
const getFreeDrawCapsulePaths = (
element: ExcalidrawFreeDrawElement,
): SVGPathString[] => {
const { points } = element;
const N = points.length;
const baseRadius = (element.strokeWidth * 1.25) / 2;
const getSmoothedPressure = (i: number): number =>
getFreeDrawSmoothedPressure(element, i);
const paths: SVGPathString[] = [];
if (N === 1) {
// Single-point stroke — filled circle.
const rr = r2(baseRadius * getSmoothedPressure(0) * 2);
const cx = r2(points[0][0]);
const cy = r2(points[0][1]);
paths.push(
`M ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} A ${rr} ${rr} 0 1 1 ${(
cx + rr
).toFixed(2)} ${cy.toFixed(2)} A ${rr} ${rr} 0 1 1 ${(cx - rr).toFixed(
2,
)} ${cy.toFixed(2)} Z` as SVGPathString,
);
return paths;
}
for (let i = 1; i < N; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
const r1 = baseRadius * getSmoothedPressure(i) * 2;
const t0 = freedrawCatmullRomTangent(points, i - 1);
const t1 = freedrawCatmullRomTangent(points, i);
// Bezier subdivision.
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
const nSubdiv = Math.max(
1,
Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING),
);
const cp1x = p0[0] + t0[0] / 3;
const cp1y = p0[1] + t0[1] / 3;
const cp2x = p1[0] - t1[0] / 3;
const cp2y = p1[1] - t1[1] / 3;
let prevX = p0[0];
let prevY = p0[1];
let prevR = r0;
for (let k = 1; k <= nSubdiv; k++) {
const t = k / nSubdiv;
const mt = 1 - t;
const mt2 = mt * mt;
const t2 = t * t;
const mt3 = mt2 * mt;
const t3 = t2 * t;
const x =
mt3 * p0[0] + 3 * mt2 * t * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0];
const y =
mt3 * p0[1] + 3 * mt2 * t * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1];
const r = r0 + (r1 - r0) * t;
paths.push(
freedrawTaperedCapsulePath(
prevX,
prevY,
prevR,
x,
y,
r,
) as SVGPathString,
);
prevX = x;
prevY = y;
prevR = r;
}
}
return paths;
};
/**
* Generates an outline polygon for a freedraw element using the same
* Catmull-Rom Bezier subdivision and pressure smoothing as the canvas renderer.
* Returns `[x, y]` points in element-local coordinates that form a closed
* polygon around the stroke (left side + right side reversed), suitable for
* hit-testing and eraser intersection.
*/
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const { points } = element;
const N = points.length;
const baseRadius = (element.strokeWidth * 1.25) / 2;
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
if (N === 0) {
return [];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
const radius0 = baseRadius * getFreeDrawSmoothedPressure(element, 0) * 2;
const max = points.length - 1;
if (N === 1) {
// Single point case
const cx = points[0][0];
const cy = points[0][1];
const result: [number, number][] = [];
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
result.push([
cx + Math.cos(angle) * radius0,
cy + Math.sin(angle) * radius0,
]);
}
return result;
}
const leftPoints: [number, number][] = [];
const rightPoints: [number, number][] = [];
for (let i = 1; i < N; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getFreeDrawSmoothedPressure(element, i - 1) * 2;
const r1 = baseRadius * getFreeDrawSmoothedPressure(element, i) * 2;
const t0 = freedrawCatmullRomTangent(points, i - 1);
const t1 = freedrawCatmullRomTangent(points, i);
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
const nSubdiv = Math.max(
1,
Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING),
);
const cp1x = p0[0] + t0[0] / 3;
const cp1y = p0[1] + t0[1] / 3;
const cp2x = p1[0] - t1[0] / 3;
const cp2y = p1[1] - t1[1] / 3;
// Include the start point of the first segment.
const kStart = i === 1 ? 0 : 1;
for (let k = kStart; k <= nSubdiv; k++) {
const tParam = k / nSubdiv;
const mt = 1 - tParam;
const mt2 = mt * mt;
const t2 = tParam * tParam;
const mt3 = mt2 * mt;
const t3 = t2 * tParam;
const x =
mt3 * p0[0] + 3 * mt2 * tParam * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0];
const y =
mt3 * p0[1] + 3 * mt2 * tParam * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1];
// Bezier first derivative for the tangent direction.
const dtx =
3 *
(mt2 * (cp1x - p0[0]) +
2 * mt * tParam * (cp2x - cp1x) +
t2 * (p1[0] - cp2x));
const dty =
3 *
(mt2 * (cp1y - p0[1]) +
2 * mt * tParam * (cp2y - cp1y) +
t2 * (p1[1] - cp2y));
const len = Math.sqrt(dtx * dtx + dty * dty);
if (len === 0) {
continue;
}
// Perpendicular (left = +, right = ).
const px = -dty / len;
const py = dtx / len;
const r = r0 + (r1 - r0) * tParam;
leftPoints.push([x + px * r, y + py * r]);
rightPoints.push([x - px * r, y - py * r]);
}
}
// Closed polygon: left side (start -> end) + right side (end -> start).
return [...leftPoints, ...rightPoints.reverse()];
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
};
// -----------------------------------------------------------------------------
@@ -1,4 +1,8 @@
import {
isCloseTo,
line,
linesIntersectAt,
pointDistance,
pointFrom,
pointRotateRads,
rangeInclusive,
@@ -13,7 +17,7 @@ import {
getDraggedElementsBounds,
getElementAbsoluteCoords,
} from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element";
import { isBoundToContainer, isElbowArrow } from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element";
@@ -29,14 +33,18 @@ import type { MaybeTransformHandleType } from "@excalidraw/element";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeletedExcalidrawElement,
NonDeleted,
} from "@excalidraw/element/types";
import type {
AppClassProperties,
AppState,
KeyboardModifiersObject,
} from "./types";
} from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "./linearElementEditor";
const SNAP_DISTANCE = 8;
@@ -122,6 +130,11 @@ 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[];
@@ -135,6 +148,27 @@ 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[];
@@ -150,6 +184,7 @@ export class SnapCache {
public static destroy = () => {
SnapCache.referenceSnapPoints = null;
SnapCache.linearElementAxisSnapTargets = null;
SnapCache.visibleGaps = null;
};
}
@@ -235,6 +270,19 @@ 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
) {
@@ -633,6 +681,227 @@ 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[],
@@ -1412,3 +1681,79 @@ 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],
};
};
+64 -62
View File
@@ -1,56 +1,59 @@
import { arrayToMap } from "@excalidraw/common";
import { arrayToMapWithIndex } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): 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)[] = [];
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
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);
}
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],
);
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
};
// `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);
const groupHandledElements = new Map<string, true>();
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);
}
} else {
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
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements;
}
return sortedElements;
return [...sortedElements];
};
/**
@@ -65,40 +68,39 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMap(elements);
const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
origElements.forEach((element, idx) => {
if (!element) {
return;
}
if (element.boundElements?.length) {
sortedElements.add(element);
for (const boundElement of element.boundElements) {
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child);
sortedElements.add(child[0]);
origElements[child[1]] = null;
}
});
} 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
}
continue;
} else {
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
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
@@ -115,5 +117,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(defragmentGroups(elements));
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
};
-20
View File
@@ -392,23 +392,3 @@ 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;
}
}
};
+3 -63
View File
@@ -1,8 +1,9 @@
/* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
InvalidFractionalIndexError,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
@@ -12,34 +13,13 @@ 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";
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();
});
});
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => {
@@ -124,46 +104,6 @@ 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 -594
View File
@@ -2,24 +2,15 @@ import {
convertToExcalidrawElements,
Excalidraw,
} from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -134,250 +125,6 @@ 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,
) => {
@@ -668,345 +415,6 @@ 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,6 +155,24 @@ 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],
@@ -258,6 +276,73 @@ 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);
@@ -401,6 +486,77 @@ 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");
+3 -43
View File
@@ -326,59 +326,19 @@ describe("normalizeElementsOrder", () => {
]),
[
"BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"YX_rect10",
"X_rect11",
"YX_rect10",
"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,7 +101,6 @@ export const actionDeselect = register({
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -119,7 +118,6 @@ export const actionDeselect = register({
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -14,8 +14,6 @@ import {
isLineElement,
} from "@excalidraw/element";
import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element";
import {
KEYS,
arrayToMap,
@@ -315,10 +313,6 @@ export const actionFinalize = register<FormData>({
}
: selectedLinearElement;
if (element && isFreeDrawElement(element)) {
invalidateFreeDrawIncrementalCanvas(element);
}
return {
elements: newElements,
appState: {
@@ -335,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,6 +205,7 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
@@ -277,6 +277,7 @@ export const actionUngroup = register({
elementsMap,
),
frame,
app,
);
}
});
@@ -1,4 +1,5 @@
import {
isWindows,
KEYS,
matchKey,
arrayToMap,
@@ -113,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
@@ -1,5 +1,3 @@
import clsx from "clsx";
import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -49,7 +47,6 @@ import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isFreeDrawElement,
isLinearElement,
isLineElement,
isTextElement,
@@ -134,8 +131,6 @@ import {
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
FreedrawPressureConstantIcon,
FreedrawPressureSensitiveIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -2046,89 +2041,3 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
);
},
});
export const actionChangeStrokeShape = register<boolean>({
name: "changeStrokeShape",
label: "labels.strokeShape",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isFreeDrawElement(el)) {
return newElementWith(el, {
simulatePressure: value,
});
}
return el;
}),
appState: { ...appState, currentItemFreedrawConstantPressure: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const { isCompact } = getStylesPanelInfo(app);
const currentValue = getFormValue(
elements,
app,
(element) => {
if (isFreeDrawElement(element)) {
return element.simulatePressure;
}
return null;
},
(element) => isFreeDrawElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemFreedrawConstantPressure,
);
if (isCompact) {
const isConstantPressure = currentValue !== false;
return (
<button
type="button"
className={clsx("compact-action-button", {
active: !isConstantPressure,
})}
title={
isConstantPressure
? t("labels.strokeShape_constant")
: t("labels.strokeShape_pressure")
}
onClick={() => updateData(!isConstantPressure)}
>
{isConstantPressure
? FreedrawPressureConstantIcon
: FreedrawPressureSensitiveIcon}
</button>
);
}
return (
<fieldset>
<legend>{t("labels.strokeShape")}</legend>
<div className="buttonList">
<RadioSelection
group="stroke-shape"
options={[
{
value: true,
text: t("labels.strokeShape_constant"),
icon: FreedrawPressureConstantIcon,
testId: "strokeShape-constant",
},
{
value: false,
text: t("labels.strokeShape_pressure"),
icon: FreedrawPressureSensitiveIcon,
testId: "strokeShape-pressure",
},
]}
value={currentValue}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
-1
View File
@@ -19,7 +19,6 @@ export {
actionChangeTextAlign,
actionChangeVerticalAlign,
actionChangeArrowProperties,
actionChangeStrokeShape,
} from "./actionProperties";
export {
@@ -1,4 +1,5 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
SVG_NS,
getSvgPathFromStroke,
@@ -7,8 +8,7 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationController } from "./renderer/animation";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App";
import type { AppState } from "./types";
@@ -34,16 +34,15 @@ 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.key = `animated-trail-${AnimatedTrail.counter++}`;
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -74,15 +73,6 @@ 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;
@@ -92,23 +82,15 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement);
}
if (!AnimationController.running(this.key)) {
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
this.animationFrameHandler.start(this);
}
stop() {
AnimationController.cancel(this.key);
this.cleanup();
this.animationFrameHandler.stop(this);
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
startPath(x: number, y: number) {
@@ -163,25 +145,21 @@ 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(
(t) =>
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
.length !== 0,
);
this.pastTrails = this.pastTrails.filter((trail) => {
return trail.getStrokeOutline().length !== 0;
});
if (paths.length === 0) {
// Clean up the SVG path if there are no trails to render
this.trailElement.setAttribute("d", "");
return false;
this.stop();
}
const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) {
this.trailElement.setAttribute(
"fill",
@@ -197,8 +175,6 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this),
);
}
return true;
}
private drawTrail(trail: LaserPointer, state: AppState): string {
@@ -0,0 +1,79 @@
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;
}
}
+2 -6
View File
@@ -40,7 +40,6 @@ export const getDefaultAppState = (): Omit<
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemFreedrawConstantPressure: true,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
@@ -100,6 +99,7 @@ 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,
@@ -172,11 +172,6 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemFreedrawConstantPressure: {
browser: true,
export: false,
server: false,
},
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
@@ -236,6 +231,7 @@ 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 },
@@ -834,14 +834,6 @@ export const CompactShapeActions = ({
container={container}
/>
{/* Stroke Shape Toggle (freedraw only) */}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && (
<div className="compact-action-item">
{renderAction("changeStrokeShape")}
</div>
)}
<CombinedArrowProperties
appState={appState}
renderAction={renderAction}
@@ -977,13 +969,6 @@ export const MobileShapeActions = ({
targetElements={targetElements}
container={container}
/>
{/* Stroke Shape Toggle (freedraw only) */}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && (
<div className="compact-action-item">
{renderAction("changeStrokeShape")}
</div>
)}
{/* Combined Arrow Properties */}
<CombinedArrowProperties
appState={appState}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
import {
isFlowchartNodeElement,
isImageElement,
isGridModeEnabled,
isLinearElement,
isLineElement,
isTextBindableContainer,
@@ -16,7 +17,6 @@ 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";
+2 -1
View File
@@ -650,7 +650,8 @@ const LayerUI = ({
};
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { cursorButton, scrollX, scrollY, ...ret } = appState;
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
appState;
return ret;
};
@@ -199,7 +199,6 @@ export default function LibraryMenuItems({
type: "everything",
elements: item.elements,
randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements,
};
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import "./SVGLayer.scss";
import type { Trail } from "../animatedTrail";
import type { Trail } from "../animated-trail";
type SVGLayerProps = {
trails: Trail[];
@@ -206,6 +206,7 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
@@ -301,6 +302,7 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
@@ -261,6 +261,7 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
@@ -415,6 +416,7 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
@@ -12,10 +12,11 @@ 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;
canvasNonce: string;
sceneNonce: number | undefined;
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.canvasNonce !== nextProps.canvasNonce ||
prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed
// even if canvasNonce didn't change (e.g. we filter elements out based
// even if sceneNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements ||
@@ -14,7 +14,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
interface NewElementCanvasProps {
appState: AppState;
newElement: NonNullable<AppState["newElement"]>;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
@@ -32,7 +31,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
{
canvas: canvasRef.current,
scale: props.scale,
newElement: props.newElement,
newElement: props.appState.newElement,
elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap,
rc: props.rc,
@@ -23,7 +23,7 @@ type StaticCanvasProps = {
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
canvasNonce: string;
sceneNonce: number | undefined;
selectionNonce: number | undefined;
scale: number;
appState: StaticCanvasAppState;
@@ -110,10 +110,10 @@ const areEqual = (
nextProps: StaticCanvasProps,
) => {
if (
prevProps.canvasNonce !== nextProps.canvasNonce ||
prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed
// even if canvasNonce didn't change (e.g. we filter elements out based
// even if sceneNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements
-20
View File
@@ -1186,26 +1186,6 @@ export const StrokeWidthExtraBoldIcon = createIcon(
modifiedTablerIconProps,
);
export const FreedrawPressureConstantIcon = createIcon(
<path
d="M4 10h12"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>,
modifiedTablerIconProps,
);
export const FreedrawPressureSensitiveIcon = createIcon(
<path
d="M4 10C6 9.5 10 8 16 7L16 13C10 12 6 10.5 4 10Z"
fill="currentColor"
stroke="none"
/>,
modifiedTablerIconProps,
);
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
+6 -3
View File
@@ -119,12 +119,15 @@ 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(2),
...SHAPES.slice(1),
] as const)
: SHAPES;
};
-8
View File
@@ -814,14 +814,6 @@ 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);
}
+4 -56
View File
@@ -311,48 +311,6 @@ 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: {
@@ -366,20 +324,6 @@ 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
@@ -403,6 +347,10 @@ 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,
+15 -94
View File
@@ -1,4 +1,4 @@
import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
import {
type CombineBrandsIfNeeded,
@@ -98,67 +98,6 @@ 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
@@ -475,15 +414,10 @@ export const restoreElement = (
return element;
case "freedraw": {
const { points, pressures } = restoreFreedrawPoints(
element.points,
element.pressures,
);
return restoreElementWithProperties(element, {
points,
points: element.points,
simulatePressure: element.simulatePressure,
pressures,
pressures: element.pressures,
});
}
case "image":
@@ -501,20 +435,14 @@ export const restoreElement = (
const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x;
let y = element.y;
let points = restoreLinearElementPoints(
element.points,
element.width,
element.height,
);
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;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords({
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawLinearElement));
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
}
return restoreElementWithProperties(element, {
@@ -528,7 +456,7 @@ export const restoreElement = (
y,
...(isLineElement(element)
? {
polygon: isValidPolygon(points)
polygon: isValidPolygon(element.points)
? element.polygon ?? false
: false,
}
@@ -543,29 +471,22 @@ export const restoreElement = (
: normalizeArrowhead(element.endArrowhead);
const x = element.x as number | undefined;
const y = element.y as number | undefined;
const points = restoreLinearElementPoints(
element.points,
element.width,
element.height,
);
const elementWithRestoredPoints = {
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawArrowElement;
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 base = {
type: element.type,
startBinding: repairBinding(
elementWithRestoredPoints,
element as ExcalidrawArrowElement,
element.startBinding,
targetElementsMap,
existingElementsMap,
"start",
),
endBinding: repairBinding(
elementWithRestoredPoints,
element as ExcalidrawArrowElement,
element.endBinding,
targetElementsMap,
existingElementsMap,
+5 -3
View File
@@ -33,7 +33,9 @@ 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 "../animatedTrail";
import { AnimatedTrail } from "../animated-trail";
import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App";
@@ -41,8 +43,8 @@ export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
constructor(app: App) {
super(app, {
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, {
streamline: 0.2,
size: 5,
keepHead: true,
+2 -30
View File
@@ -6,12 +6,7 @@ import React, {
useState,
} from "react";
import {
applyDarkModeFilter,
DEFAULT_IMAGE_OPTIONS,
DEFAULT_UI_OPTIONS,
isShallowEqual,
} from "@excalidraw/common";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
import App, {
ExcalidrawAPIContext,
@@ -103,7 +98,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled,
showDeprecatedFonts,
renderScrollbars,
imageOptions,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -134,13 +128,6 @@ 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);
@@ -221,7 +208,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars}
imageOptions={normalizedImageOptions}
>
{children}
</App>
@@ -239,13 +225,11 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
const {
initialData: prevInitialData,
UIOptions: prevUIOptions = {},
imageOptions: prevImageOptions,
...prev
} = prevProps;
const {
initialData: nextInitialData,
UIOptions: nextUIOptions = {},
imageOptions: nextImageOptions,
...next
} = nextProps;
@@ -289,17 +273,7 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
return prevUIOptions[key] === nextUIOptions[key];
});
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);
return isUIOptionsSame && isShallowEqual(prev, next);
};
export const Excalidraw = React.memo(ExcalidrawBase, areEqual);
@@ -451,5 +425,3 @@ export function useExcalidrawStateValue(
// -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
export { applyDarkModeFilter };
@@ -2,20 +2,27 @@ import { DEFAULT_LASER_COLOR, easeOut } from "@excalidraw/common";
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimatedTrail } from "./animatedTrail";
import { AnimatedTrail } from "./animated-trail";
import { getClientColor } from "./clients";
import type { Trail } from "./animatedTrail";
import type { Trail } from "./animated-trail";
import type { AnimationFrameHandler } from "./animation-frame-handler";
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 app: App) {
this.localTrail = new AnimatedTrail(app, {
constructor(
private animationFrameHandler: AnimationFrameHandler,
private app: App,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
...this.getTrailOptions(),
fill: () => DEFAULT_LASER_COLOR,
});
@@ -56,45 +63,30 @@ 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;
}
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);
}
}
onFrame() {
this.updateCollabTrails();
}
updateCollabTrails(collaborators: App["state"]["collaborators"]) {
this.stopCollabTrails(collaborators);
if (!this.container || collaborators.size === 0) {
private updateCollabTrails() {
if (!this.container || this.app.state.collaborators.size === 0) {
return;
}
for (const [key, collaborator] of collaborators.entries()) {
// Current user has their own trail drawn via localTrail
if (collaborator.isCurrentUser) {
continue;
}
for (const [key, collaborator] of this.app.state.collaborators.entries()) {
let trail!: AnimatedTrail;
// 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, {
if (!this.collabTrails.has(key)) {
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
...this.getTrailOptions(),
fill: () =>
collaborator.pointer?.laserColor ||
@@ -103,33 +95,36 @@ 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") {
const buttonDown = collaborator.button === "down";
const buttonUp = collaborator.button === "up";
const hasTrail = trail.hasCurrentTrail;
// Initialize a new trail
if (buttonDown && !hasTrail) {
if (collaborator.button === "down" && !trail.hasCurrentTrail) {
trail.startPath(collaborator.pointer.x, collaborator.pointer.y);
}
// Add only original points
const lastPointOriginal = !trail.hasLastPoint(
collaborator.pointer.x,
collaborator.pointer.y,
);
if (buttonDown && lastPointOriginal) {
if (
collaborator.button === "down" &&
trail.hasCurrentTrail &&
!trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y)
) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
}
// End the trail on button up
if (buttonUp && hasTrail) {
if (collaborator.button === "up" && trail.hasCurrentTrail) {
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);
}
}
}
}
+5 -3
View File
@@ -25,7 +25,9 @@ import type {
NonDeleted,
} from "@excalidraw/element/types";
import { AnimatedTrail } from "../animatedTrail";
import { type AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils";
@@ -45,8 +47,8 @@ export class LassoTrail extends AnimatedTrail {
private canvasTranslate: CanvasTranslate | null = null;
private keepPreviousSelection: boolean = false;
constructor(app: App) {
super(app, {
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, {
animateTrail: true,
streamline: 0.4,
sizeMapping: (c) => {
-3
View File
@@ -30,9 +30,6 @@
"changeBackground": "Change background color",
"fill": "Fill",
"strokeWidth": "Stroke width",
"strokeShape": "Pressure",
"strokeShape_constant": "Constant pressure",
"strokeShape_pressure": "Pressure-sensitive",
"strokeStyle": "Stroke style",
"strokeStyle_solid": "Solid",
"strokeStyle_dashed": "Dashed",
+2
View File
@@ -95,6 +95,7 @@
"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",
@@ -103,6 +104,7 @@
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"pako": "2.0.3",
"perfect-freehand": "1.2.0",
"pica": "7.1.1",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
-88
View File
@@ -23,94 +23,6 @@ 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 = "";
+16 -58
View File
@@ -6,10 +6,7 @@ export type Animation<R extends object> = (params: {
}) => R | null | undefined;
export class AnimationController {
private static scheduledFrame:
| { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
| { id: ReturnType<typeof setTimeout>; type: "timeout" }
| null = null;
private static isRunning = false;
private static animations = new Map<
string,
{
@@ -20,10 +17,6 @@ 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,
@@ -36,54 +29,19 @@ export class AnimationController {
state: initialState,
});
AnimationController.scheduleNextFrame();
if (!AnimationController.isRunning) {
AnimationController.isRunning = true;
if (isRenderThrottlingEnabled()) {
requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
}
}
}
}
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();
@@ -98,7 +56,8 @@ export class AnimationController {
if (!state) {
AnimationController.animations.delete(key);
if (AnimationController.cancelScheduledFrameIfIdle()) {
if (AnimationController.animations.size === 0) {
AnimationController.isRunning = false;
return;
}
} else {
@@ -107,11 +66,11 @@ export class AnimationController {
}
}
if (AnimationController.cancelScheduledFrameIfIdle()) {
return;
if (isRenderThrottlingEnabled()) {
requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
}
AnimationController.scheduleNextFrame();
}
}
@@ -121,6 +80,5 @@ export class AnimationController {
static cancel(key: string) {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
}
}
+4 -4
View File
@@ -62,10 +62,10 @@ export const bootstrapCanvas = ({
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillStyle =
theme === THEME.DARK
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {
+2 -1
View File
@@ -2,7 +2,8 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import { THEME } from "@excalidraw/common";
import type { PointSnapLine, PointerSnapLine } from "../snapping";
import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element";
import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b";
+11 -22
View File
@@ -377,27 +377,18 @@ const renderElementToSvg = (
case "freedraw": {
const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
// Set fill once on the group so all capsule paths inherit it
// instead of repeating the attribute on every child element.
wrapper.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
const shapes = ShapeCache.generateElementShape(element, renderConfig);
// always ordered as [background, stroke]
for (const shape of shapes) {
if (typeof shape === "string") {
// stroke (SVGPathString) — fill inherited from wrapper <g>
// stroke (SVGPathString)
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
path.setAttribute(
"fill",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
path.setAttribute("d", shape);
wrapper.appendChild(path);
@@ -630,10 +621,9 @@ const renderElementToSvg = (
rect.setAttribute("fill", "none");
rect.setAttribute(
"stroke",
applyDarkModeFilter(
FRAME_STYLE.strokeColor,
renderConfig.theme === THEME.DARK,
),
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor,
);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
@@ -687,10 +677,9 @@ const renderElementToSvg = (
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute(
"fill",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
+103 -210
View File
@@ -1,15 +1,9 @@
import {
getCommonFrameId,
getFrameChildrenInsertionIndex,
isElementInViewport,
} from "@excalidraw/element";
import { isElementInViewport } from "@excalidraw/element";
import { arrayToMap, memoize, toBrandedType } from "@excalidraw/common";
import { memoize, toBrandedType } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
@@ -22,21 +16,6 @@ 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;
@@ -44,121 +23,9 @@ export class Renderer {
this.scene = scene;
}
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,
public getRenderableElements = (() => {
const getVisibleCanvasElements = ({
elementsMap,
zoom,
offsetLeft,
offsetTop,
@@ -166,27 +33,70 @@ 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,
newElement,
}: Omit<
GetRenderableElementsOpts,
| "selectedElements"
| "selectedElementsAreBeingDragged"
| "frameToHighlight"
> & {
canvasNonce: string;
newElementId,
}: {
elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined;
}) => {
const elements = this.scene.getNonDeletedElements();
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
const { elementsMap, newElementCanvasElement } =
this.getRenderableElementsMap({
elements,
editingTextElement,
newElement,
});
for (const element of elements) {
if (newElementId === element.id) {
continue;
}
const visibleElements = this.getVisibleCanvasElements({
elementsMap,
// 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(
({
zoom,
offsetLeft,
offsetTop,
@@ -194,69 +104,52 @@ 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();
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 elementsMap = getRenderableElements({
elements,
editingTextElement,
newElementId,
});
return {
...ret,
visibleElements: reorderedVisibleElements,
};
}
const visibleElements = getVisibleCanvasElements({
elementsMap,
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
});
return ret;
};
return { elementsMap, visibleElements };
},
);
})();
// 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();
}
}
+3 -1
View File
@@ -459,7 +459,9 @@ export const exportToSvg = async (
rect.setAttribute("height", `${height}`);
rect.setAttribute(
"fill",
applyDarkModeFilter(viewBackgroundColor, exportWithDarkMode),
exportWithDarkMode
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
);
svgRoot.appendChild(rect);
}
@@ -83,26 +83,6 @@ 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(
@@ -119,7 +99,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(normalizeDialogSnapshot(dialog)).toMatchSnapshot();
expect(dialog.outerHTML).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"><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" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD
@@ -898,7 +898,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -980,6 +979,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1098,7 +1098,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -1174,6 +1173,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1313,7 +1313,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -1389,6 +1388,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1645,7 +1645,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -1721,6 +1720,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1977,7 +1977,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2053,6 +2052,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2192,7 +2192,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2266,6 +2265,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2434,7 +2434,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2510,6 +2509,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2733,7 +2733,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2814,6 +2813,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3106,7 +3106,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemFillStyle": "cross-hatch",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 60,
"currentItemRoughness": 2,
"currentItemRoundness": "sharp",
@@ -3182,6 +3181,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3600,7 +3600,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -3676,6 +3675,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3924,7 +3924,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -4000,6 +3999,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4248,7 +4248,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -4327,6 +4326,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5534,7 +5534,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -5613,6 +5612,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6752,7 +6752,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -6833,6 +6832,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7710,7 +7710,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -7787,6 +7786,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8711,7 +8711,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -8787,6 +8786,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9703,7 +9703,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9782,6 +9781,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,6 @@ exports[`given element A and group of elements B and given both are selected whe
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -107,6 +106,7 @@ 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,
@@ -451,7 +451,6 @@ exports[`given element A and group of elements B and given both are selected whe
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -536,6 +535,7 @@ 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,
@@ -868,7 +868,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -944,6 +943,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1435,7 +1435,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -1511,6 +1510,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1643,7 +1643,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -1724,6 +1723,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2028,7 +2028,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2106,6 +2105,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2274,7 +2274,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2350,6 +2349,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2455,7 +2455,6 @@ exports[`regression tests > can drag element that covers another element, while
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2533,6 +2532,7 @@ exports[`regression tests > can drag element that covers another element, while
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2781,7 +2781,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -2857,6 +2856,7 @@ 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,
@@ -3037,7 +3037,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -3115,6 +3114,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3279,7 +3279,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -3357,6 +3356,7 @@ 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,
@@ -3516,7 +3516,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -3594,6 +3593,7 @@ 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,
@@ -3775,7 +3775,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -3854,6 +3853,7 @@ 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,
@@ -4090,7 +4090,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -4168,6 +4167,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4527,7 +4527,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -4632,6 +4631,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4811,7 +4811,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -4888,6 +4887,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5088,7 +5088,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -5192,6 +5191,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5297,7 +5297,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -5373,6 +5372,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5498,7 +5498,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -5574,6 +5573,7 @@ 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,
@@ -5892,7 +5892,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -5972,6 +5971,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6190,7 +6190,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -6933,7 +6932,7 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
],
"roughness": 1,
"roundness": null,
"simulatePressure": true,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -6980,7 +6979,6 @@ exports[`regression tests > given a group of selected elements with an element t
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -7059,6 +7057,7 @@ 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,
@@ -7315,7 +7314,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -7394,6 +7392,7 @@ 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,
@@ -7595,7 +7594,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -7673,6 +7671,7 @@ 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,
@@ -7831,7 +7830,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -7909,6 +7907,7 @@ 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,
@@ -8072,7 +8071,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -8148,6 +8146,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8253,7 +8252,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -8329,6 +8327,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8434,7 +8433,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -8510,6 +8508,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8615,7 +8614,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -8667,7 +8665,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"preferredSelectionTool": {
@@ -8849,7 +8854,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9081,7 +9085,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9155,6 +9158,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9227,7 +9231,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
],
"roughness": 1,
"roundness": null,
"simulatePressure": true,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -9274,7 +9278,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9326,7 +9329,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"originSnapOffset": null,
"originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"preferredSelectionTool": {
@@ -9508,7 +9518,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9584,6 +9593,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9689,7 +9699,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9921,7 +9930,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -9997,6 +10005,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10102,7 +10111,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -10176,6 +10184,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10248,7 +10257,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
],
"roughness": 1,
"roundness": null,
"simulatePressure": true,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -10295,7 +10304,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -10371,6 +10379,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10476,7 +10485,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -10560,6 +10568,7 @@ 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,
@@ -11008,7 +11017,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -11086,6 +11094,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11289,7 +11298,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -11363,6 +11371,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11413,7 +11422,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -11489,6 +11497,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11614,7 +11623,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -11694,6 +11702,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11934,7 +11943,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -12016,6 +12024,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12364,7 +12373,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -12450,6 +12458,7 @@ 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,
@@ -13005,7 +13014,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -13082,6 +13090,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13132,7 +13141,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -13210,6 +13218,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13764,7 +13773,6 @@ exports[`regression tests > switches from group of selected elements to another
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -13871,6 +13879,7 @@ exports[`regression tests > switches from group of selected elements to another
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14104,7 +14113,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -14210,6 +14218,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14369,7 +14378,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -14443,6 +14451,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14493,7 +14502,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -14859,7 +14867,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"currentItemFillStyle": "solid",
"currentItemFontFamily": 8,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -14933,6 +14940,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14983,7 +14991,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemFreedrawConstantPressure": true,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "sharp",
@@ -15060,6 +15067,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1,73 +0,0 @@
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);
});
});
+7 -90
View File
@@ -305,88 +305,8 @@ 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);
});
});
@@ -459,9 +379,8 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(3);
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[1].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(null);
});
@@ -503,11 +422,10 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(3);
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].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[2].id).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(frame.id);
});
});
@@ -555,9 +473,8 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(4);
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[1].type).toBe(rect.type);
expect(h.elements[1].frameId).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,39 +160,6 @@ 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" });
@@ -433,52 +400,6 @@ 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,42 +94,3 @@ 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,
});
};
@@ -2120,9 +2120,6 @@ describe("history", () => {
await render(
<Excalidraw handleKeyboardGlobally={true} isCollaborating={true} />,
);
// Pin the One Euro Filter clock to a fixed value so the smoothed
// intermediate freedraw point is deterministic regardless of system load.
vi.spyOn(h.app as any, "getPointerEventTimestamp").mockReturnValue(0);
});
it("should not override remote changes on different elements", async () => {
+2 -63
View File
@@ -1,4 +1,4 @@
import { MIME_TYPES, randomId, reseed } from "@excalidraw/common";
import { randomId, reseed } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
@@ -17,41 +17,18 @@ 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} {...props} />,
);
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
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();
@@ -135,42 +112,4 @@ 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.",
);
});
});
});
+1 -33
View File
@@ -10,7 +10,7 @@ import { API } from "./helpers/api";
import { Pointer } from "./helpers/ui";
import { act, GlobalTestState, render, waitFor } from "./test-utils";
import type { Collaborator, ExcalidrawProps, SocketId } from "../types";
import type { ExcalidrawProps } from "../types";
describe("laser tool interactions", () => {
const h = window.h;
@@ -128,36 +128,4 @@ 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);
});
});
+3 -336
View File
@@ -1,12 +1,8 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, ROUNDNESS, arrayToMap, reseed } from "@excalidraw/common";
import {
getElementBounds,
getElementLineSegments,
getElementsWithinSelection,
} from "@excalidraw/element";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes";
@@ -273,145 +269,6 @@ 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",
@@ -758,32 +615,6 @@ 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", () => {
@@ -859,175 +690,11 @@ 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",
@@ -1058,7 +725,7 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.move(-1000, -1000);
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
mouse.moveTo(rect2.x - 10, rect2.y - 10);
+1 -27
View File
@@ -4,12 +4,10 @@ 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 { AppClassProperties, ExcalidrawImperativeAPI } from "../types";
import type { ExcalidrawImperativeAPI } from "../types";
describe("setActiveTool()", () => {
const h = window.h;
@@ -68,27 +66,3 @@ 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);
});
});
+8 -33
View File
@@ -10,6 +10,8 @@ import type { LinearElementEditor } from "@excalidraw/element";
import type { MaybeTransformHandleType } from "@excalidraw/element";
import type { SnapLine } from "@excalidraw/element";
import type {
PointerType,
ExcalidrawLinearElement,
@@ -55,7 +57,6 @@ 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";
@@ -315,10 +316,7 @@ export interface AppState {
bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */
isMidpointSnappingEnabled: boolean;
/**
* The bindable element the UI highlights for the user when an arrow is
* dragged or otherwise its endpoint being close to said element.
*/
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBinding: {
element: NonDeleted<ExcalidrawBindableElement>;
midPoint?: GlobalPoint;
@@ -350,11 +348,8 @@ export interface AppState {
type: "selection" | "lasso";
initialized: boolean;
};
// Pen handling
penMode: boolean;
penDetected: boolean;
exportBackground: boolean;
exportEmbedScene: boolean;
exportWithDarkMode: boolean;
@@ -363,7 +358,6 @@ export interface AppState {
currentItemBackgroundColor: string;
currentItemFillStyle: ExcalidrawElement["fillStyle"];
currentItemStrokeWidth: number;
currentItemFreedrawConstantPressure: boolean;
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemRoughness: number;
currentItemOpacity: number;
@@ -479,9 +473,6 @@ 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;
}
@@ -497,7 +488,10 @@ export type SearchMatch = {
}[];
};
export type UIAppState = Omit<AppState, "cursorButton" | "scrollX" | "scrollY">;
export type UIAppState = Omit<
AppState,
"startBoundElement" | "cursorButton" | "scrollX" | "scrollY"
>;
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@@ -646,10 +640,6 @@ 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>;
@@ -736,11 +726,6 @@ 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
@@ -782,7 +767,6 @@ export type AppProps = Merge<
canvasActions: Required<CanvasActions> & { export: ExportOpts };
}
>;
imageOptions: Required<ImageOptions>;
detectScroll: boolean;
handleKeyboardGlobally: boolean;
isCollaborating: boolean;
@@ -887,13 +871,8 @@ 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
@@ -910,10 +889,7 @@ export type PointerDownState = Readonly<{
// We need to have these in the state so that we can unsubscribe them
eventListeners: {
// It's defined on the initial pointer down event
onMove:
| null
| ReturnType<typeof throttleRAF>
| (((event: PointerEvent) => void) & { flush(): void; cancel(): void });
onMove: null | ReturnType<typeof throttleRAF>;
// It's defined on the initial pointer down event
onUp: null | ((event: PointerEvent) => void);
// It's defined on the initial pointer down event
@@ -922,7 +898,6 @@ export type PointerDownState = Readonly<{
onKeyUp: null | ((event: KeyboardEvent) => void);
};
boxSelection: {
// If the box selection tool is activated on pointer down
hasOccurred: boolean;
};
}>;
@@ -769,63 +769,6 @@ 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",
+4 -4
View File
@@ -392,10 +392,10 @@ export const textWysiwyg = ({
),
textAlign,
verticalAlign,
color: applyDarkModeFilter(
updatedTextElement.strokeColor,
appState.theme === THEME.DARK,
),
color:
appState.theme === THEME.DARK
? applyDarkModeFilter(updatedTextElement.strokeColor)
: updatedTextElement.strokeColor,
opacity: updatedTextElement.opacity / 100,
maxHeight: `${editorMaxHeight}px`,
});
View File
-45
View File
@@ -1,45 +0,0 @@
{
"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/*"
]
}

Some files were not shown because too many files have changed in this diff Show More