mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-20 00:16:54 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2f5e7eae | ||
|
|
c61a483f88 | ||
|
|
a918648012 | ||
|
|
4e758db17e | ||
|
|
afa3a653fc | ||
|
|
6574de60b3 | ||
|
|
854d00c31b | ||
|
|
0361bdd9cb | ||
|
|
2b1c2a9fbf | ||
|
|
f1a79b73df | ||
|
|
214cd6e6e8 | ||
|
|
e1bb9ff8f8 | ||
|
|
4a6f4e7a32 | ||
|
|
e160ff7ba0 | ||
|
|
884225b1c7 | ||
|
|
abeeaeba21 | ||
|
|
c5a50d223f | ||
|
|
cf212b2f33 | ||
|
|
4872083c04 | ||
|
|
ea74787447 | ||
|
|
219571a718 | ||
|
|
e4ab626739 | ||
|
|
1e22618ab2 | ||
|
|
74789584be | ||
|
|
e33a1c0e85 | ||
|
|
5e375b4331 | ||
|
|
85270fcc8d | ||
|
|
46c42a6cb0 | ||
|
|
c9c96f619c | ||
|
|
1da120e914 | ||
|
|
ab0255f21e | ||
|
|
792ea3a06c | ||
|
|
f1aa8f86a9 | ||
|
|
39103bd33a | ||
|
|
786ab266ff | ||
|
|
69d4c346cc | ||
|
|
1acf66edab | ||
|
|
7c0b329576 | ||
|
|
b2e81e38a6 | ||
|
|
f179f7ffd2 | ||
|
|
e6ae6bf057 | ||
|
|
91b78903ab | ||
|
|
a3b90897b5 | ||
|
|
53732f08f4 |
@@ -0,0 +1,4 @@
|
||||
# Guidelines
|
||||
|
||||
- For new DOM/browser API usage, use `app.ownerDocument` and `app.ownerWindow` instead of globals; without `app`, derive them from the mounted node's `ownerDocument` and its `defaultView`.
|
||||
- When overriding properties of an existing type, prefer `Merge<Base, Overrides>` from `@excalidraw/common/utility-types` over `Omit<Base, keyof Overrides> & Overrides`.
|
||||
@@ -274,6 +274,35 @@ convertToExcalidrawElements([
|
||||
|
||||

|
||||
|
||||
### Sticky Notes
|
||||
|
||||
A sticky note is an always-filled note whose label auto-fits: the font shrinks from the label's `fontSize` (the note's font ceiling) down to a minimum, and only then does the note grow in height. Only `type`, `x` and `y` are required; `width`/`height` default to a 250×250 square and `label` is optional. The note's `strokeColor` is its ink — its text and creation-date footer are always one color; a label that sets its own `strokeColor` gives it to the note. A footer band below the label shows the note's creation date (`created`); pass `created: null` for a note without one (the band stays reserved, but empty).
|
||||
|
||||
```js
|
||||
convertToExcalidrawElements([
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "Remember to ship it",
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 400,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
backgroundColor: "#fcc2d7",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
Notes keep their invariants regardless of the properties you pass: the background is never transparent (it falls back to the default yellow), the fill is solid, and the size is at least 75×75. The height you pass is kept as the note's `baseHeight`; if a label needs more room at the minimum font size, the created note is taller.
|
||||
|
||||
### Labelled Arrows
|
||||
|
||||
Similar to Text Containers, you can create labelled arrows as well.
|
||||
|
||||
@@ -62,6 +62,18 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
||||
}
|
||||
```
|
||||
|
||||
### Element creation time
|
||||
|
||||
Every normalized element has a `created: number | null` field. A number is the client's wall-clock time in epoch milliseconds when this element instance was created. `null` means the creation time is unknown; restoring an older file does not substitute its load time or last-update time.
|
||||
|
||||
| Operation | `created` |
|
||||
| --- | --- |
|
||||
| Construct a new element | Current time, unless explicitly supplied (including `null`) |
|
||||
| Duplicate, paste, or insert from a library | New time for the new instance |
|
||||
| Convert element skeletons with ID regeneration | New time for each resulting instance |
|
||||
| Restore/open/import an existing scene | Preserve the carried value; missing values become `null` |
|
||||
| Edit, change type while retaining the ID, capture, undo, or redo | Preserve the value |
|
||||
|
||||
## Excalidraw clipboard format
|
||||
|
||||
When copying selected excalidraw elements to clipboard, the JSON schema is similar to `.excalidraw` format, except it differs in attributes.
|
||||
|
||||
+48
-1
@@ -33,7 +33,7 @@ import {
|
||||
isDevEnv,
|
||||
} from "@excalidraw/common";
|
||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from "@excalidraw/excalidraw/data/restore";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
@@ -97,6 +98,7 @@ import Collab, {
|
||||
collabAPIAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
userToFollowAtom,
|
||||
} from "./collab/Collab";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
@@ -139,6 +141,7 @@ import DebugCanvas, {
|
||||
isVisualDebuggerEnabled,
|
||||
loadSavedDebugState,
|
||||
} from "./components/DebugCanvas";
|
||||
import { useSimulatedCollaborators } from "./debugCollaborators";
|
||||
import { AIComponents } from "./components/AI";
|
||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
|
||||
@@ -241,6 +244,7 @@ const initializeScene = async (opts: {
|
||||
elements: restoreElements(localDataState?.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
schemaVersion: localDataState?.schemaVersion,
|
||||
}),
|
||||
appState: restoreAppState(localDataState?.appState, null),
|
||||
};
|
||||
@@ -267,6 +271,7 @@ const initializeScene = async (opts: {
|
||||
restoreElements(imported.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
schemaVersion: getSchemaVersion(imported),
|
||||
}),
|
||||
localDataState?.elements,
|
||||
),
|
||||
@@ -409,6 +414,37 @@ const ExcalidrawWrapper = () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||
const userToFollow = useAtomValue(userToFollowAtom);
|
||||
|
||||
const viewportStatusFrame = useMemo(
|
||||
() =>
|
||||
userToFollow
|
||||
? {
|
||||
border: "var(--color-primary-hover)",
|
||||
label: {
|
||||
label: (
|
||||
<>
|
||||
Following{" "}
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 100,
|
||||
}}
|
||||
title={userToFollow.username}
|
||||
>
|
||||
{userToFollow.username}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
onClose: () => collabAPI?.setUserToFollow(null),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[userToFollow, collabAPI],
|
||||
);
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
@@ -434,6 +470,11 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
// ?collaborators=<N> — populate the canvas with N static fake
|
||||
// collaborators for exercising avatar/UserList UI without a real
|
||||
// collab room
|
||||
useSimulatedCollaborators(excalidrawAPI);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -545,8 +586,12 @@ const ExcalidrawWrapper = () => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
// NOTE: scene elements passed through restore during
|
||||
// initialization, so they are already at the current
|
||||
// schema version
|
||||
elements: restoreElements(data.scene.elements, null, {
|
||||
repairBindings: true,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
}),
|
||||
appState: restoreAppState(data.scene.appState, null),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -908,6 +953,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
viewportStatusFrame={viewportStatusFrame}
|
||||
userToFollow={userToFollow}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const ROOM_ID_BYTES = 10;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_SCHEMA_VERSION: "excalidraw-schema-version",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
|
||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
SocketId,
|
||||
Collaborator,
|
||||
Gesture,
|
||||
UserToFollow,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
@@ -108,6 +110,7 @@ interface CollabState {
|
||||
}
|
||||
|
||||
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||
export const userToFollowAtom = atom<UserToFollow | null>(null);
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
|
||||
@@ -123,6 +126,7 @@ export interface CollabAPI {
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setCollabError: CollabInstance["setErrorDialog"];
|
||||
setUserToFollow: CollabInstance["setUserToFollow"];
|
||||
}
|
||||
|
||||
interface CollabProps {
|
||||
@@ -139,6 +143,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
/** the socket ids of the users following the current user */
|
||||
private followedBy = new Set<SocketId>();
|
||||
|
||||
constructor(props: CollabProps) {
|
||||
super(props);
|
||||
@@ -212,7 +218,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
|
||||
this.portal.socket && this.portal.broadcastUserFollowed(payload);
|
||||
this.setUserToFollow(
|
||||
payload.action === "FOLLOW" ? payload.userToFollow : null,
|
||||
);
|
||||
});
|
||||
const throttledRelayUserViewportBounds = throttleRAF(
|
||||
this.relayVisibleSceneBounds,
|
||||
@@ -238,6 +246,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setCollabError: this.setErrorDialog,
|
||||
setUserToFollow: this.setUserToFollow,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
@@ -406,9 +415,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
this.followedBy = new Set();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setActiveRoomLink(null);
|
||||
appJotaiStore.set(userToFollowAtom, null);
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
@@ -587,8 +598,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
const remoteElements = toBrandedType<
|
||||
readonly RemoteExcalidrawElement[]
|
||||
>(decryptedData.payload.elements);
|
||||
const reconciledElements =
|
||||
this._reconcileElements(remoteElements);
|
||||
const reconciledElements = this._reconcileElements(
|
||||
remoteElements,
|
||||
getSchemaVersion(decryptedData.payload),
|
||||
);
|
||||
this.handleRemoteSceneUpdate(reconciledElements);
|
||||
// noop if already resolved via init from firebase
|
||||
scenePromise.resolve({
|
||||
@@ -604,6 +617,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
toBrandedType<readonly RemoteExcalidrawElement[]>(
|
||||
decryptedData.payload.elements,
|
||||
),
|
||||
getSchemaVersion(decryptedData.payload),
|
||||
),
|
||||
);
|
||||
break;
|
||||
@@ -629,11 +643,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
|
||||
const { sceneBounds, socketId } = decryptedData.payload;
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
const userToFollow = appJotaiStore.get(userToFollowAtom);
|
||||
|
||||
// we're not following the user
|
||||
// (shouldn't happen, but could be late message or bug upstream)
|
||||
if (appState.userToFollow?.socketId !== socketId) {
|
||||
if (userToFollow?.socketId !== socketId) {
|
||||
console.warn(
|
||||
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
|
||||
);
|
||||
@@ -641,13 +655,12 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
|
||||
// cross-follow case, ignore updates in this case
|
||||
if (
|
||||
appState.userToFollow &&
|
||||
appState.followedBy.has(appState.userToFollow.socketId)
|
||||
) {
|
||||
if (this.followedBy.has(userToFollow.socketId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: zoomToFitBounds({
|
||||
appState,
|
||||
@@ -689,9 +702,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.portal.socket.on(
|
||||
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
|
||||
(followedBy: SocketId[]) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { followedBy: new Set(followedBy) },
|
||||
});
|
||||
this.followedBy = new Set(followedBy);
|
||||
|
||||
this.relayVisibleSceneBounds({ force: true });
|
||||
},
|
||||
@@ -753,15 +764,27 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
|
||||
private _reconcileElements = (
|
||||
remoteElements: readonly RemoteExcalidrawElement[],
|
||||
remoteSchemaVersion: number = 0,
|
||||
): ReconciledExcalidrawElement[] => {
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
const existingElements = this.getSceneElementsIncludingDeleted();
|
||||
|
||||
if (remoteSchemaVersion > CURRENT_SCHEMA_VERSION) {
|
||||
// NOTE: remote client is newer than us; we can't downgrade its elements,
|
||||
// so restore as-is and rely on unknown properties being preserved
|
||||
console.warn(
|
||||
`Received collab update with schema version ${remoteSchemaVersion} ` +
|
||||
`(local: ${CURRENT_SCHEMA_VERSION})`,
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE ideally we restore _after_ reconciliation but we can't do that
|
||||
// as we'd regenerate even elements such as appState.newElement which would
|
||||
// break the state
|
||||
remoteElements = restoreElements(remoteElements, existingElements);
|
||||
remoteElements = restoreElements(remoteElements, existingElements, {
|
||||
schemaVersion: remoteSchemaVersion,
|
||||
});
|
||||
|
||||
let reconciledElements = reconcileElements(
|
||||
existingElements,
|
||||
@@ -869,26 +892,38 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
const isCurrentUser = socketId === this.portal.socket?.id;
|
||||
collaborators.set(
|
||||
socketId,
|
||||
Object.assign({}, this.collaborators.get(socketId), {
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
}),
|
||||
Object.assign(
|
||||
// we never receive our own broadcasts, so we need to seed
|
||||
// our own collaborator entry with the local username
|
||||
isCurrentUser ? { username: this.state.username } : {},
|
||||
this.collaborators.get(socketId),
|
||||
{ isCurrentUser },
|
||||
),
|
||||
);
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
|
||||
// unfollow if the followed user left the room
|
||||
const userToFollow = appJotaiStore.get(userToFollowAtom);
|
||||
if (userToFollow && !collaborators.has(userToFollow.socketId)) {
|
||||
this.setUserToFollow(null);
|
||||
}
|
||||
}
|
||||
|
||||
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
|
||||
const isCurrentUser = socketId === this.portal.socket?.id;
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user: Mutable<Collaborator> = Object.assign(
|
||||
{},
|
||||
// we never receive our own broadcasts, so we need to seed
|
||||
// our own collaborator entry with the local username
|
||||
isCurrentUser ? { username: this.state.username } : {},
|
||||
collaborators.get(socketId),
|
||||
updates,
|
||||
{
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
},
|
||||
{ isCurrentUser },
|
||||
);
|
||||
collaborators.set(socketId, user);
|
||||
this.collaborators = collaborators;
|
||||
@@ -924,12 +959,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
);
|
||||
|
||||
relayVisibleSceneBounds = (props?: { force: boolean }) => {
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
|
||||
if (this.portal.socket && (this.followedBy.size > 0 || props?.force)) {
|
||||
this.portal.broadcastVisibleSceneBounds(
|
||||
{
|
||||
sceneBounds: getVisibleSceneBounds(appState),
|
||||
sceneBounds: getVisibleSceneBounds(this.excalidrawAPI.getAppState()),
|
||||
},
|
||||
`follow@${this.portal.socket.id}`,
|
||||
);
|
||||
@@ -984,9 +1017,37 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
{ leading: false },
|
||||
);
|
||||
|
||||
setUserToFollow = (userToFollow: UserToFollow | null) => {
|
||||
const prev = appJotaiStore.get(userToFollowAtom) ?? null;
|
||||
|
||||
if (prev?.socketId !== userToFollow?.socketId && this.portal.socket) {
|
||||
// leave the previous user's follow room before joining the next one
|
||||
if (prev) {
|
||||
this.portal.broadcastUserFollowed({
|
||||
userToFollow: prev,
|
||||
action: "UNFOLLOW",
|
||||
});
|
||||
}
|
||||
if (userToFollow) {
|
||||
this.portal.broadcastUserFollowed({
|
||||
userToFollow,
|
||||
action: "FOLLOW",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
appJotaiStore.set(userToFollowAtom, userToFollow);
|
||||
};
|
||||
|
||||
setUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
|
||||
// keep our own collaborator entry in sync
|
||||
const socketId = this.portal.socket?.id as SocketId | undefined;
|
||||
if (socketId && this.collaborators.has(socketId)) {
|
||||
this.updateCollaborator(socketId, { username });
|
||||
}
|
||||
};
|
||||
|
||||
getUsername = () => this.state.username;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { CURRENT_SCHEMA_VERSION } from "@excalidraw/element";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
@@ -167,6 +168,7 @@ class Portal {
|
||||
type: updateType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
getNonDeletedElements,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
parseSSEStream,
|
||||
TTDDialog,
|
||||
TTDStreamFetch,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
|
||||
import type { StreamChunk } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
|
||||
@@ -22,7 +24,7 @@ export const AIComponents = ({
|
||||
return (
|
||||
<>
|
||||
<DiagramToCodePlugin
|
||||
generate={async ({ frame, children }) => {
|
||||
generate={async ({ frame, children, onPartial }) => {
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
|
||||
// SAFETY: This should never happen, but log it just in case
|
||||
@@ -51,11 +53,11 @@ export const AIComponents = ({
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/diagram-to-code/generate`,
|
||||
}/v1/ai/diagram-to-code/generate-streaming`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -94,18 +96,57 @@ export const AIComponents = ({
|
||||
throw new Error(errorJSON.message || text);
|
||||
}
|
||||
|
||||
try {
|
||||
const { html } = await response.json();
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!html) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
return {
|
||||
html,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (!reader) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let streamError: Error | null = null;
|
||||
|
||||
for await (const data of parseSSEStream(reader)) {
|
||||
if (data === "[DONE]") {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = safelyParseJSON(data) as StreamChunk | null;
|
||||
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "content": {
|
||||
if (chunk.delta) {
|
||||
html += chunk.delta;
|
||||
onPartial?.(html);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
streamError = new Error(
|
||||
chunk.error.message || "Generation failed",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
if (!html.trim()) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ExcalLogo,
|
||||
eyeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
import { MainMenu } from "@excalidraw/excalidraw/index";
|
||||
import React from "react";
|
||||
|
||||
@@ -22,6 +23,7 @@ export const AppMainMenu: React.FC<{
|
||||
theme: Theme | "system";
|
||||
refresh: () => void;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
@@ -56,7 +58,7 @@ export const AppMainMenu: React.FC<{
|
||||
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||
className="highlighted"
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||
{isExcalidrawPlusSignedUser ? t("labels.signIn") : t("labels.signUp")}
|
||||
</MainMenu.ItemLink>
|
||||
{isDevEnv() && (
|
||||
<MainMenu.Item
|
||||
|
||||
@@ -72,7 +72,7 @@ export const AppWelcomeScreen: React.FC<{
|
||||
shortcut={null}
|
||||
icon={loginIcon}
|
||||
>
|
||||
Sign up
|
||||
{t("labels.signUp")}
|
||||
</WelcomeScreen.Center.MenuItemLink>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "idb-keyval";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { CURRENT_SCHEMA_VERSION } from "@excalidraw/element";
|
||||
|
||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
@@ -91,6 +92,10 @@ const saveDataStateToLocalStorage = (
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(getNonDeletedElements(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_SCHEMA_VERSION,
|
||||
JSON.stringify(CURRENT_SCHEMA_VERSION),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(_appState),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
|
||||
import { getSceneVersion } from "@excalidraw/element";
|
||||
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
|
||||
import { initializeApp } from "firebase/app";
|
||||
import {
|
||||
getFirestore,
|
||||
@@ -86,6 +87,7 @@ export const loadFirebaseStorage = async () => {
|
||||
|
||||
type FirebaseStoredScene = {
|
||||
sceneVersion: number;
|
||||
schemaVersion?: number;
|
||||
iv: Bytes;
|
||||
ciphertext: Bytes;
|
||||
};
|
||||
@@ -179,6 +181,7 @@ const createFirebaseSceneDocument = async (
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
return {
|
||||
sceneVersion,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
|
||||
iv: Bytes.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
@@ -216,7 +219,9 @@ export const saveToFirebase = async (
|
||||
|
||||
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
||||
const prevStoredElements = getSyncableElements(
|
||||
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
||||
restoreElements(await decryptElements(prevStoredScene, roomKey), null, {
|
||||
schemaVersion: getSchemaVersion(prevStoredScene),
|
||||
}),
|
||||
);
|
||||
const reconciledElements = getSyncableElements(
|
||||
reconcileElements(
|
||||
@@ -238,7 +243,9 @@ export const saveToFirebase = async (
|
||||
});
|
||||
|
||||
const storedElements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
schemaVersion: getSchemaVersion(storedScene),
|
||||
}),
|
||||
);
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||
@@ -261,6 +268,7 @@ export const loadFromFirebase = async (
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
schemaVersion: getSchemaVersion(storedScene),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { getSchemaVersion } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { bytesToHexString } from "@excalidraw/common";
|
||||
|
||||
@@ -84,12 +85,14 @@ export type SocketUpdateDataSource = {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
schemaVersion?: number;
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
schemaVersion?: number;
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
@@ -196,6 +199,7 @@ const legacy_decodeFromBackend = async ({
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
schemaVersion: getSchemaVersion(data),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -226,6 +230,7 @@ export const importFromBackend = async (
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
schemaVersion: getSchemaVersion(data),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
getDefaultAppState,
|
||||
} from "@excalidraw/excalidraw/appState";
|
||||
|
||||
import { getSchemaVersion } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
@@ -37,10 +39,14 @@ export const importUsernameFromLocalStorage = (): string | null => {
|
||||
export const importFromLocalStorage = () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
let savedSchemaVersion = null;
|
||||
|
||||
try {
|
||||
savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
savedSchemaVersion = localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_SCHEMA_VERSION,
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
@@ -56,6 +62,17 @@ export const importFromLocalStorage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
let schemaVersion: number | undefined;
|
||||
if (savedSchemaVersion) {
|
||||
try {
|
||||
schemaVersion = getSchemaVersion({
|
||||
schemaVersion: JSON.parse(savedSchemaVersion),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
let appState = null;
|
||||
if (savedState) {
|
||||
try {
|
||||
@@ -70,7 +87,7 @@ export const importFromLocalStorage = () => {
|
||||
// Do nothing because appState is already null
|
||||
}
|
||||
}
|
||||
return { elements, appState };
|
||||
return { elements, appState, schemaVersion };
|
||||
};
|
||||
|
||||
export const getElementsStorageSize = () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { isDevEnv, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
Collaborator,
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
/**
|
||||
* Dev-only debug helper: `?collaborators=<N>` on excalidraw-app populates
|
||||
* the canvas with N static fake collaborators (random usernames, one of
|
||||
* them marked as the current user) so avatar/UserList UI can be exercised
|
||||
* without a real collab room or a second browser tab.
|
||||
*
|
||||
* Not meant to coexist with a real collab session — joining an actual room
|
||||
* will overwrite these with the real roster (Collab owns `collaborators`
|
||||
* from that point on).
|
||||
*/
|
||||
|
||||
const getSimulatedCollaboratorCountFromUrl = (): number | null => {
|
||||
const count = Number(
|
||||
new URLSearchParams(window.location.search).get("collaborators"),
|
||||
);
|
||||
return Number.isFinite(count) && count > 0 ? Math.floor(count) : null;
|
||||
};
|
||||
|
||||
const generateSimulatedCollaborators = async (
|
||||
count: number,
|
||||
): Promise<Map<SocketId, Collaborator>> => {
|
||||
const { getRandomUsername } = await import("@excalidraw/random-username");
|
||||
|
||||
const collaborators = new Map<SocketId, Collaborator>();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const socketId = toBrandedType<SocketId>(`debug-collaborator-${i}`);
|
||||
collaborators.set(socketId, {
|
||||
id: socketId,
|
||||
socketId,
|
||||
username: getRandomUsername(),
|
||||
isCurrentUser: i === 0,
|
||||
});
|
||||
}
|
||||
|
||||
return collaborators;
|
||||
};
|
||||
|
||||
export const useSimulatedCollaborators = (
|
||||
excalidrawAPI: ExcalidrawImperativeAPI | null,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isDevEnv() || !excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = getSimulatedCollaboratorCountFromUrl();
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
generateSimulatedCollaborators(count).then((collaborators) => {
|
||||
if (!cancelled) {
|
||||
excalidrawAPI.updateScene({ collaborators });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
removeDarkModeFilter,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -215,6 +216,24 @@ describe("applyDarkModeFilter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDarkModeFilter", () => {
|
||||
it("restores black and white", () => {
|
||||
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
|
||||
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it.each([
|
||||
COLOR_PALETTE.black,
|
||||
COLOR_PALETTE.white,
|
||||
COLOR_PALETTE.red[4],
|
||||
COLOR_PALETTE.green[4],
|
||||
COLOR_PALETTE.blue[4],
|
||||
])("preserves the rendered dark-mode color for %s", (color) => {
|
||||
const filtered = applyDarkModeFilter(color);
|
||||
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math";
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
|
||||
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
@@ -96,12 +99,17 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const inverted = cssInvert(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_INVERT_PERCENT,
|
||||
);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
@@ -113,6 +121,44 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const _reverseDarkModeInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
|
||||
const denominator = 1 - 2 * p;
|
||||
const restore = (color: number) =>
|
||||
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
|
||||
|
||||
return {
|
||||
r: restore(r),
|
||||
g: restore(g),
|
||||
b: restore(b),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeDarkModeFilter = (color: string): string => {
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
const rgb = tc.toRgb();
|
||||
|
||||
// 180deg hue rotation is its own inverse, so undo it before the inversion.
|
||||
const rotatedBack = cssHueRotate(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
const restored = _reverseDarkModeInvert(
|
||||
rotatedBack.r,
|
||||
rotatedBack.g,
|
||||
rotatedBack.b,
|
||||
);
|
||||
|
||||
return rgbToHex(restored.r, restored.g, restored.b, alpha);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,6 +230,11 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** number of slots in the color-picker top-picks strip — the strip layout is
|
||||
* sized for exactly this many swatches, and pick customization (replace /
|
||||
* reorder) preserves it */
|
||||
export const COLOR_TOP_PICKS_SLOTS = 5;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
||||
COLOR_PALETTE.black,
|
||||
@@ -202,6 +253,33 @@ export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker.
|
||||
// Same as element background picks, with `transparent` (a no-op fill for the
|
||||
// bucket tool) swapped for white.
|
||||
export const BUCKET_FILL_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// sticky notes are their own color domain (own defaults, own picks)
|
||||
export const DEFAULT_STICKY_NOTE_BG = "#ffdf6b";
|
||||
|
||||
export const STICKY_NOTE_STROKE_PICKS: ColorTuple =
|
||||
DEFAULT_ELEMENT_STROKE_PICKS;
|
||||
|
||||
// ORDER matters for positioning in quick picker. Classic note colors; the
|
||||
// first one is `DEFAULT_STICKY_NOTE_BG`. Never transparent.
|
||||
export const STICKY_NOTE_BACKGROUND_PICKS = [
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
COLOR_PALETTE.pink[1],
|
||||
COLOR_PALETTE.green[1],
|
||||
COLOR_PALETTE.blue[1],
|
||||
COLOR_PALETTE.orange[1],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
@@ -282,6 +360,19 @@ export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
return hex6;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns the color with its alpha replaced by `alpha` (as #RRGGBBAA), or the
|
||||
* input unchanged if it's not a valid color
|
||||
*/
|
||||
export const setColorAlpha = (color: string, alpha: number): string => {
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
return color;
|
||||
}
|
||||
const { r, g, b } = tc.toRgb();
|
||||
return rgbToHex(r, g, b, alpha);
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
|
||||
* null if not valid color
|
||||
@@ -299,6 +390,17 @@ export const isTransparent = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the color is fully opaque (alpha exactly 1).
|
||||
*
|
||||
* NOT the negation of `isTransparent`, which is true only at alpha 0 — a
|
||||
* partial-alpha color (`#RRGGBBAA`, `rgba(…, 0.5)`) is neither transparent
|
||||
* (it renders) nor opaque (what's beneath shows through it).
|
||||
*/
|
||||
export const isOpaqueColor = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 1;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// color contract helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export { DEFAULT_STICKY_NOTE_BG } from "./colors";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -69,6 +71,7 @@ export enum EVENT {
|
||||
POINTER_MOVE = "pointermove",
|
||||
POINTER_DOWN = "pointerdown",
|
||||
POINTER_UP = "pointerup",
|
||||
POINTER_CANCEL = "pointercancel",
|
||||
STATE_CHANGE = "statechange",
|
||||
WHEEL = "wheel",
|
||||
TOUCH_START = "touchstart",
|
||||
@@ -211,6 +214,50 @@ export const FRAME_STYLE = {
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const STICKY_NOTE_MIN_FONT_SIZE = 16;
|
||||
export const STICKY_NOTE_MAX_FONT_SIZE = 512;
|
||||
export const STICKY_NOTE_FALLBACK_FONT_SIZE = 28;
|
||||
export const STICKY_NOTE_FONT_STEP = 2;
|
||||
export const STICKY_NOTE_PADDING = 16;
|
||||
/**
|
||||
* The creation-date footer: a 20px text row under the label body plus a 12px
|
||||
* gap above it, inside the note's bottom padding. Reserved for every note —
|
||||
* also when `created` is unknown — so geometry never depends on data
|
||||
* availability. The label is chosen by width bucket, never measured, so
|
||||
* painting stays measurement-free (the server has no text measurer):
|
||||
* `minBodyWidthForYear` is the worst case ("30 May 2026") in the system sans
|
||||
* stack at 12px, with margin for wider fallbacks such as DejaVu Sans.
|
||||
*/
|
||||
/**
|
||||
* The creation-date footer of a sticky note. The label body ends `height`
|
||||
* above the note's bottom padding, and the date's baseline sits
|
||||
* `baselineFromBottom` above the note's bottom edge — so the 12px glyphs
|
||||
* (~9px above the baseline, ~3px below) end up roughly 11px from the edge
|
||||
* with a ~13px gap to the label body above them.
|
||||
*/
|
||||
export const STICKY_NOTE_FOOTER = {
|
||||
height: 20,
|
||||
fontSize: 12,
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
baselineFromBottom: 14,
|
||||
opacity: 1,
|
||||
minBodyWidthForYear: 80,
|
||||
} as const;
|
||||
/**
|
||||
* outer height → label body height: top + bottom padding + footer. The body
|
||||
* is what the label is fitted into; a middle-aligned label is still centered
|
||||
* in the whole padded note when it fits (see `computeBoundTextPosition`).
|
||||
*/
|
||||
export const STICKY_NOTE_BODY_INSET_Y =
|
||||
STICKY_NOTE_PADDING * 2 + STICKY_NOTE_FOOTER.height;
|
||||
export const DEFAULT_STICKY_NOTE_SIZE = 250;
|
||||
// floor for a finalized note's width and base height; the UI floor is font-aware
|
||||
// on top of it (see `getStickyNoteMinSize`)
|
||||
export const STICKY_NOTE_MIN_SIZE = 75;
|
||||
export const STICKY_NOTE_SHADOW_OFFSET = 3;
|
||||
export const STICKY_NOTE_SHADOW_OPACITY = 0.16;
|
||||
export const STICKY_NOTE_EDGE_SHADOW_WIDTH = 0.5;
|
||||
export const STICKY_NOTE_EDGE_SHADOW_OPACITY = 0.08;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
@@ -371,6 +418,7 @@ export const TEXT_ALIGN = {
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
export const ELEMENT_PENDING_DRAW_SHAPE_OPACITY = 70;
|
||||
|
||||
// Radius represented as 25% of element's largest side (width/height).
|
||||
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
|
||||
@@ -496,8 +544,11 @@ export const TOOL_TYPE = {
|
||||
hand: "hand",
|
||||
frame: "frame",
|
||||
magicframe: "magicframe",
|
||||
stickynote: "stickynote",
|
||||
embeddable: "embeddable",
|
||||
laser: "laser",
|
||||
autoshape: "autoshape",
|
||||
bucketfill: "bucketfill",
|
||||
} as const;
|
||||
|
||||
export const EDITOR_LS_KEYS = {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const KEYS = {
|
||||
SLASH: "/",
|
||||
|
||||
A: "a",
|
||||
B: "b",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
@@ -59,6 +60,7 @@ export const KEYS = {
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
N: "n",
|
||||
O: "o",
|
||||
P: "p",
|
||||
Q: "q",
|
||||
|
||||
@@ -14,3 +14,20 @@ export const reseed = (seed: number) => {
|
||||
};
|
||||
|
||||
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) for a given seed: same seed, same
|
||||
* sequence — used where a render must be stable across frames and clients
|
||||
* (e.g. the sticky note's corner jitter) and roughjs's `Random` is
|
||||
* unsuitable (it degenerates for seed 0). Returns numbers in [0, 1).
|
||||
*/
|
||||
export const seededRandom = (seed: number) => {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value += 0x6d2b79f5;
|
||||
let next = value;
|
||||
next = Math.imul(next ^ (next >>> 15), next | 1);
|
||||
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
|
||||
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
getNearestScrollableContainer,
|
||||
isInputLike,
|
||||
isInteractive,
|
||||
isToolIcon,
|
||||
isTransparent,
|
||||
isWritableElement,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
@@ -11,6 +16,34 @@ import { throttleRAF } from "./utils";
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("cross-document element guards", () => {
|
||||
it("uses constructors from the target element's window", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
|
||||
const iframeDocument = iframe.contentDocument!;
|
||||
const iframeWindow = iframe.contentWindow!;
|
||||
const textarea = iframeDocument.createElement("textarea");
|
||||
const button = iframeDocument.createElement("button");
|
||||
const toolIcon = iframeDocument.createElement("div");
|
||||
toolIcon.className = "ToolIcon__icon";
|
||||
|
||||
expect(textarea).not.toBeInstanceOf(HTMLTextAreaElement);
|
||||
expect(textarea).toBeInstanceOf(
|
||||
(iframeWindow as Window & typeof globalThis).HTMLTextAreaElement,
|
||||
);
|
||||
expect(isInputLike(textarea)).toBe(true);
|
||||
expect(isWritableElement(textarea)).toBe(true);
|
||||
expect(isInteractive(button)).toBe(true);
|
||||
expect(isToolIcon(toolIcon)).toBe(true);
|
||||
|
||||
iframeDocument.body.append(textarea);
|
||||
expect(getNearestScrollableContainer(textarea)).toBe(iframeDocument);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
|
||||
@@ -48,10 +48,23 @@ export const getDateTime = () => {
|
||||
export const capitalizeString = (str: string) =>
|
||||
str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
const getTargetWindow = (
|
||||
target: Element | EventTarget | null,
|
||||
): (Window & typeof globalThis) | null =>
|
||||
(target as (EventTarget & { ownerDocument?: Document | null }) | null)
|
||||
?.ownerDocument?.defaultView ??
|
||||
(typeof window === "undefined" ? null : window);
|
||||
|
||||
export const isToolIcon = (
|
||||
target: Element | EventTarget | null,
|
||||
): target is HTMLElement =>
|
||||
target instanceof HTMLElement && target.className.includes("ToolIcon");
|
||||
): target is HTMLElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
target instanceof targetWindow.HTMLElement &&
|
||||
target.className.includes("ToolIcon")
|
||||
);
|
||||
};
|
||||
|
||||
export const isInputLike = (
|
||||
target: Element | EventTarget | null,
|
||||
@@ -60,17 +73,26 @@ export const isInputLike = (
|
||||
| HTMLTextAreaElement
|
||||
| HTMLSelectElement
|
||||
| HTMLBRElement
|
||||
| HTMLDivElement =>
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement;
|
||||
| HTMLDivElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
((target instanceof targetWindow.HTMLElement &&
|
||||
target.dataset.type === "wysiwyg") ||
|
||||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof targetWindow.HTMLInputElement ||
|
||||
target instanceof targetWindow.HTMLTextAreaElement ||
|
||||
target instanceof targetWindow.HTMLSelectElement)
|
||||
);
|
||||
};
|
||||
|
||||
export const isInteractive = (target: Element | EventTarget | null) => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
isInputLike(target) ||
|
||||
(target instanceof Element && !!target.closest("label, button"))
|
||||
(!!targetWindow &&
|
||||
target instanceof targetWindow.Element &&
|
||||
!!target.closest("label, button"))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,16 +102,23 @@ export const isWritableElement = (
|
||||
| HTMLInputElement
|
||||
| HTMLTextAreaElement
|
||||
| HTMLBRElement
|
||||
| HTMLDivElement =>
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
| HTMLDivElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
((target instanceof targetWindow.HTMLElement &&
|
||||
target.dataset.type === "wysiwyg") ||
|
||||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof targetWindow.HTMLTextAreaElement ||
|
||||
(target instanceof targetWindow.HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof targetWindow.HTMLElement &&
|
||||
target.closest(".cm-editor") !== null))
|
||||
);
|
||||
};
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -475,12 +504,14 @@ export const supportsEmoji = () => {
|
||||
export const getNearestScrollableContainer = (
|
||||
element: HTMLElement,
|
||||
): HTMLElement | Document => {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView ?? window;
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
if (parent === document.body) {
|
||||
return document;
|
||||
if (parent === ownerDocument.body) {
|
||||
return ownerDocument;
|
||||
}
|
||||
const { overflowY } = window.getComputedStyle(parent);
|
||||
const { overflowY } = ownerWindow.getComputedStyle(parent);
|
||||
const hasScrollableContent = parent.scrollHeight > parent.clientHeight;
|
||||
if (
|
||||
hasScrollableContent &&
|
||||
@@ -492,7 +523,7 @@ export const getNearestScrollableContainer = (
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return document;
|
||||
return ownerDocument;
|
||||
};
|
||||
|
||||
export const focusNearestParent = (element: HTMLInputElement) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -51,6 +52,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -83,6 +85,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -144,6 +147,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -210,6 +214,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -242,6 +247,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id50",
|
||||
@@ -249,6 +255,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -259,6 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -288,6 +296,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id50",
|
||||
@@ -295,6 +304,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -305,6 +315,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -339,6 +350,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -400,8 +412,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id50",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -412,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -446,6 +461,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -507,8 +523,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id39",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -519,6 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -553,6 +572,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -590,6 +610,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -627,6 +648,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -688,8 +710,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id43",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -700,6 +724,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -729,6 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id43",
|
||||
@@ -736,6 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -746,6 +773,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -775,6 +803,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id43",
|
||||
@@ -782,6 +811,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -792,6 +822,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -821,6 +852,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -853,6 +885,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -900,6 +933,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "triangle",
|
||||
@@ -947,6 +981,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
@@ -994,6 +1029,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
@@ -1041,6 +1077,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1073,6 +1110,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1105,6 +1143,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1137,6 +1176,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#c0eb75",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1169,6 +1209,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#ffc9c9",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1201,6 +1242,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
@@ -1233,8 +1275,10 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1245,6 +1289,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1274,8 +1319,10 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1286,6 +1333,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1324,6 +1372,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1363,6 +1412,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1406,6 +1456,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1453,6 +1504,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1492,6 +1544,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1560,6 +1613,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1619,8 +1673,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "B",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1633,6 +1689,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1662,8 +1719,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "A",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1676,6 +1735,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1705,8 +1765,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Alice",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1719,6 +1781,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a8",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1748,8 +1811,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1762,6 +1827,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a9",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1791,8 +1857,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_Alice",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1803,6 +1871,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "aA",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1832,8 +1901,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_B",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1844,6 +1915,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "aB",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1859,12 +1931,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": 27.016999999999996,
|
||||
"y": 66.5,
|
||||
"x": -50,
|
||||
"y": -12.5,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1878,6 +1950,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1930,6 +2003,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1982,6 +2056,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -2034,6 +2109,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -2081,8 +2157,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id25",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2093,6 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a4",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2122,8 +2201,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id26",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2134,6 +2215,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a5",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2163,8 +2245,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id27",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2175,6 +2259,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2205,8 +2290,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id28",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2217,6 +2304,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2252,6 +2340,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2289,6 +2378,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2326,6 +2416,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2363,6 +2454,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2400,6 +2492,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2437,6 +2530,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2469,8 +2563,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id13",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2481,6 +2577,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2510,8 +2607,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id14",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2522,6 +2621,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2552,8 +2652,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id15",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2564,6 +2666,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a8",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2596,8 +2699,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id16",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2608,6 +2713,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a9",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2638,8 +2744,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id17",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2650,6 +2758,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "aA",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2681,8 +2790,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id18",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2693,6 +2804,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "aB",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ROUNDNESS } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Scene } from "../Scene";
|
||||
import { addNewNodes } from "../flowchart";
|
||||
import { newStickyNoteElement } from "../newElement";
|
||||
import { isFlowchartNodeElement, isStickyNoteElement } from "../typeChecks";
|
||||
|
||||
describe("flowchart", () => {
|
||||
it("creates connected sticky notes", () => {
|
||||
const sticky = newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 240,
|
||||
height: 260,
|
||||
baseHeight: 220,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 2,
|
||||
backgroundColor: "#ffec99",
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
const scene = new Scene([sticky], { skipValidation: true });
|
||||
const {
|
||||
nodes: [nextNode, bindingArrow],
|
||||
} = addNewNodes(
|
||||
sticky,
|
||||
{
|
||||
currentItemEndArrowhead: "arrow",
|
||||
} as AppState,
|
||||
"right",
|
||||
scene,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(isFlowchartNodeElement(sticky)).toBe(true);
|
||||
expect(isFlowchartNodeElement(nextNode)).toBe(true);
|
||||
expect(isStickyNoteElement(nextNode)).toBe(true);
|
||||
expect(nextNode).toMatchObject({
|
||||
type: "stickynote",
|
||||
x: sticky.x + sticky.width + 100,
|
||||
y: sticky.y,
|
||||
width: sticky.width,
|
||||
height: sticky.height,
|
||||
baseHeight: sticky.baseHeight,
|
||||
roundness: sticky.roundness,
|
||||
roughness: sticky.roughness,
|
||||
backgroundColor: sticky.backgroundColor,
|
||||
strokeColor: sticky.strokeColor,
|
||||
strokeWidth: sticky.strokeWidth,
|
||||
});
|
||||
expect(bindingArrow).toMatchObject({
|
||||
type: "arrow",
|
||||
startBinding: { elementId: sticky.id },
|
||||
endBinding: { elementId: nextNode.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,30 @@
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_FONT_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
} from "../newElement";
|
||||
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
type ExcalidrawElementSkeleton,
|
||||
} from "../transform";
|
||||
|
||||
import type { ExcalidrawArrowElement } from "../types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../types";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@@ -965,4 +983,208 @@ describe("Test Transform", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("creation timestamps", () => {
|
||||
const createElements = () => [
|
||||
{
|
||||
...newElement({ type: "rectangle", x: 0, y: 0 }),
|
||||
type: "rectangle" as const,
|
||||
},
|
||||
newFreeDrawElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
simulatePressure: false,
|
||||
}),
|
||||
newIframeElement({ type: "iframe", x: 0, y: 0 }),
|
||||
newEmbeddableElement({ type: "embeddable", x: 0, y: 0 }),
|
||||
];
|
||||
|
||||
it("assigns fresh timestamps when regenerating IDs without mutating the input", () => {
|
||||
const elements = createElements().map((element) => ({
|
||||
...element,
|
||||
created: 123,
|
||||
}));
|
||||
const converted = convertToExcalidrawElements(elements);
|
||||
|
||||
expect(converted).toHaveLength(elements.length);
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).not.toBe(elements[index].id);
|
||||
expect(element.created).toBe(getUpdatedTimestamp());
|
||||
expect(elements[index].created).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([123, 0, null])(
|
||||
"preserves an explicit timestamp of %s when retaining IDs",
|
||||
(created) => {
|
||||
const elements = createElements().map((element) => ({
|
||||
...element,
|
||||
created,
|
||||
}));
|
||||
const converted = convertToExcalidrawElements(elements, opts);
|
||||
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).toBe(elements[index].id);
|
||||
expect(element.created).toBe(created);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults missing timestamps when retaining IDs, as every type goes through a constructor", () => {
|
||||
// models skeletons persisted before `created` existed; the declared
|
||||
// skeleton type is a complete element
|
||||
const elements = createElements().map(
|
||||
({ created, ...element }) => element,
|
||||
) as unknown as ExcalidrawElementSkeleton[];
|
||||
const converted = convertToExcalidrawElements(elements, opts);
|
||||
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).toBe(elements[index].id);
|
||||
expect(element.created).toBe(getUpdatedTimestamp());
|
||||
expect(elements[index]).not.toHaveProperty("created");
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns fresh timestamps to generated labels and binding endpoints", () => {
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
created: 123,
|
||||
// fragments cannot carry a creation time; they are always new
|
||||
label: { text: "label" },
|
||||
start: { type: "rectangle" },
|
||||
end: { type: "text", text: "end" },
|
||||
},
|
||||
];
|
||||
const converted = convertToExcalidrawElements(elements);
|
||||
|
||||
expect(converted).toHaveLength(4);
|
||||
expect(converted.map((element) => element.created)).toEqual(
|
||||
Array(4).fill(getUpdatedTimestamp()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sticky notes", () => {
|
||||
const find = <
|
||||
T extends ExcalidrawStickyNoteElement | ExcalidrawTextElement,
|
||||
>(
|
||||
elements: readonly { type: string }[],
|
||||
type: T["type"],
|
||||
) => elements.find((element) => element.type === type) as T;
|
||||
|
||||
it("creates a finalized note with the sticky defaults", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[{ type: "stickynote", x: 100, y: 100 }],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(note.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
|
||||
expect(note.fillStyle).toBe("solid");
|
||||
});
|
||||
|
||||
it("enforces the note invariants on the given properties", () => {
|
||||
const note = find<ExcalidrawStickyNoteElement>(
|
||||
convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 300,
|
||||
backgroundColor: "transparent",
|
||||
strokeColor: "transparent",
|
||||
},
|
||||
],
|
||||
opts,
|
||||
),
|
||||
"stickynote",
|
||||
);
|
||||
|
||||
expect(note.width).toBe(STICKY_NOTE_MIN_SIZE);
|
||||
expect(note.height).toBe(300);
|
||||
expect(note.baseHeight).toBe(300);
|
||||
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
|
||||
expect(note.strokeColor).not.toBe("transparent");
|
||||
});
|
||||
|
||||
it("gives the note the color of a label that sets its own", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#1971c2",
|
||||
label: { text: "hello", strokeColor: "#e03131" },
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(label.strokeColor).toBe("#e03131");
|
||||
expect(note.strokeColor).toBe("#e03131");
|
||||
});
|
||||
|
||||
it("binds a label whose font size becomes the note's ceiling", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#1971c2",
|
||||
label: { text: "hello", fontSize: 28 },
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(elements).toHaveLength(2);
|
||||
expect(label.containerId).toBe(note.id);
|
||||
expect(note.boundElements).toEqual([{ type: "text", id: label.id }]);
|
||||
expect(label.baseFontSize).toBe(28);
|
||||
expect(label.fontSize).toBe(28);
|
||||
// the label is the note's visible text: it takes the note's stroke
|
||||
expect(label.strokeColor).toBe("#1971c2");
|
||||
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
|
||||
it("grows the note for a label that overflows at the minimum font size", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
label: {
|
||||
text: Array(40).fill("abcdefghijklmnopqrstuvwx").join("\n"),
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(label.fontSize).toBe(STICKY_NOTE_MIN_FONT_SIZE);
|
||||
expect(label.baseFontSize).toBe(28);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.height).toBeGreaterThan(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
getSchemaVersion,
|
||||
migrateElement,
|
||||
migrateElementPartial,
|
||||
migrateElements,
|
||||
} from "../versioning";
|
||||
|
||||
describe("scene schema versioning", () => {
|
||||
describe("getSchemaVersion", () => {
|
||||
it("treats a container without schemaVersion as legacy (version 0)", () => {
|
||||
expect(getSchemaVersion({})).toBe(0);
|
||||
expect(getSchemaVersion({ schemaVersion: undefined })).toBe(0);
|
||||
expect(getSchemaVersion(null)).toBe(0);
|
||||
expect(getSchemaVersion(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats an invalid schemaVersion as legacy (version 0)", () => {
|
||||
expect(getSchemaVersion({ schemaVersion: "2" })).toBe(0);
|
||||
expect(getSchemaVersion({ schemaVersion: NaN })).toBe(0);
|
||||
expect(getSchemaVersion({ schemaVersion: Infinity })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the container's schemaVersion when present", () => {
|
||||
expect(getSchemaVersion({ schemaVersion: 1 })).toBe(1);
|
||||
expect(getSchemaVersion({ schemaVersion: 3 })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateElements", () => {
|
||||
it("lifts legacy elements through the whole migration chain", () => {
|
||||
const legacy = [{ id: "a", type: "rectangle", backgroundColor: "#fff" }];
|
||||
|
||||
const migrated = migrateElements(legacy, 0);
|
||||
|
||||
// v0 -> v1 migration keeps the element shape intact
|
||||
expect(migrated).toEqual(legacy);
|
||||
});
|
||||
|
||||
it("returns the input array unchanged when already current", () => {
|
||||
const elements = [{ id: "a", type: "rectangle" }];
|
||||
|
||||
expect(migrateElements(elements, CURRENT_SCHEMA_VERSION)).toBe(elements);
|
||||
});
|
||||
|
||||
it("returns the input array unchanged when coming from a newer client", () => {
|
||||
const elements = [{ id: "a", type: "rectangle" }];
|
||||
|
||||
expect(migrateElements(elements, CURRENT_SCHEMA_VERSION + 1)).toBe(
|
||||
elements,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateElement", () => {
|
||||
it("does not mutate the input element", () => {
|
||||
const legacy = { id: "a", type: "rectangle" };
|
||||
|
||||
const migrated = migrateElement(legacy, 0);
|
||||
|
||||
expect(migrated).toEqual({ id: "a", type: "rectangle" });
|
||||
expect(legacy).toEqual({ id: "a", type: "rectangle" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateElementPartial", () => {
|
||||
it("lifts a legacy delta partial to the current schema version", () => {
|
||||
const partial = { backgroundColor: "#fff", version: 2 };
|
||||
|
||||
expect(migrateElementPartial(partial, 0)).toEqual(partial);
|
||||
});
|
||||
|
||||
it("returns the partial unchanged when already current", () => {
|
||||
const partial = { backgroundColor: "#fff" };
|
||||
|
||||
expect(migrateElementPartial(partial, CURRENT_SCHEMA_VERSION)).toBe(
|
||||
partial,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Template for future migrations
|
||||
it("runs migrations in sequence up to the current version", () => {
|
||||
const migrated = migrateElement({ id: "a" }, 0);
|
||||
|
||||
expect(migrated).toEqual({ id: "a" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Text bound to an arrow endpoint — creating a text element that reads as a
|
||||
* label for the tip of an arrow, placed and anchored so the arrow itself never
|
||||
* moves.
|
||||
*
|
||||
* The App-side interaction (hover affordance, hit gating, creation/drag entry
|
||||
* points) lives in `App.arrowText.ts`; the generic binding bookkeeping these
|
||||
* helpers rely on stays in `binding.ts`.
|
||||
*/
|
||||
import { TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
pointDistance,
|
||||
pointFromVector,
|
||||
pointsEqual,
|
||||
vector,
|
||||
vectorDot,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBindingGap, normalizeFixedPoint } from "./binding";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
compareHeading,
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getTextAnchorRatios } from "./newElement";
|
||||
import { isArrowElement, isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPoint,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "./types";
|
||||
|
||||
export type ArrowEndpoint = {
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>;
|
||||
startOrEnd: "start" | "end";
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a free (unbound) arrow endpoint under the cursor, front-most first.
|
||||
* Used by the text tool to offer creating a text label anchored to the tip of
|
||||
* an arrow.
|
||||
*/
|
||||
export const getUnboundArrowEndpointAtPoint = (
|
||||
scenePointer: GlobalPoint,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
): ArrowEndpoint | null => {
|
||||
// front to back, so the top-most arrow wins when endpoints overlap
|
||||
for (let index = elements.length - 1; index >= 0; index--) {
|
||||
const element = elements[index];
|
||||
|
||||
if (
|
||||
!isArrowElement(element) ||
|
||||
element.locked ||
|
||||
element.points.length < 2
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// prefer the end (arrowhead) over the start when both are within range
|
||||
for (const startOrEnd of ["end", "start"] as const) {
|
||||
if (element[startOrEnd === "start" ? "startBinding" : "endBinding"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// a zero-length tail has no direction to place a text along, so
|
||||
// `getTextBindingForArrowEndpoint` would refuse the endpoint — don't
|
||||
// offer it at all rather than highlight something a click won't honor
|
||||
if (
|
||||
pointsEqual(
|
||||
element.points[
|
||||
startOrEnd === "start" ? 0 : element.points.length - 1
|
||||
],
|
||||
element.points[
|
||||
startOrEnd === "start" ? 1 : element.points.length - 2
|
||||
],
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// same reach as grabbing a point handle in the linear editor
|
||||
// (`LinearElementEditor.getPointIndexUnderCursor`), which can't be
|
||||
// reused here: it reports whichever point is nearest, with no way to
|
||||
// skip an endpoint that is already bound and fall through to the other
|
||||
if (
|
||||
pointDistance(scenePointer, point) * zoom.value <
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
return { arrow: element, startOrEnd };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes how a text element should be created so that it reads as a label
|
||||
* for the given arrow endpoint.
|
||||
*
|
||||
* The arrow is treated as fixed: rather than routing the arrow to the text, we
|
||||
* pick the side of the text the arrow should attach to (the one it already
|
||||
* points at), and place the text so that side's midpoint lands on the existing
|
||||
* endpoint. The alignment returned pins that same midpoint while the text is
|
||||
* typed, so the arrow doesn't swing around during editing.
|
||||
*/
|
||||
export const getTextBindingForArrowEndpoint = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
/**
|
||||
* stroke width the text will be created with — the binding gap is derived
|
||||
* from the *bind target*, so using the arrow's would offset the anchor by
|
||||
* half the difference between the two
|
||||
*/
|
||||
targetStrokeWidth: number,
|
||||
): {
|
||||
/** the text-local ratio the arrow binds to (a side midpoint) */
|
||||
fixedPoint: FixedPoint;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
/** scene position the text's bound side midpoint should sit at */
|
||||
anchor: GlobalPoint;
|
||||
} | null => {
|
||||
const endpoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const neighbor = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (pointsEqual(endpoint, neighbor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The direction the arrow travels as it reaches this endpoint, snapped to
|
||||
// the dominant axis — i.e. the direction the text should extend away from
|
||||
// the tip.
|
||||
//
|
||||
// Deliberately not `vectorToHeading`: its tie-break resolves an exact 45°
|
||||
// vector to a direction the arrow isn't travelling in at all (a perfectly
|
||||
// down-right vector yields UP), which would put the text on the wrong side.
|
||||
// Here a tie picks the horizontal axis, always with the correct sign.
|
||||
const direction = vectorFromPoint(endpoint, neighbor);
|
||||
const heading: Heading =
|
||||
Math.abs(direction[0]) >= Math.abs(direction[1])
|
||||
? direction[0] >= 0
|
||||
? HEADING_RIGHT
|
||||
: HEADING_LEFT
|
||||
: direction[1] >= 0
|
||||
? HEADING_DOWN
|
||||
: HEADING_UP;
|
||||
|
||||
// Keep the tip off the text's bounding box by the usual binding gap so the
|
||||
// arrowhead doesn't collide with the glyphs. Must match the gap
|
||||
// `updateBoundPoint` will resolve the binding with, or the first text update
|
||||
// shifts the arrow. Elbow arrows terminate on the fixed point itself rather
|
||||
// than gap-outside the outline, so offsetting there would just shift them.
|
||||
const gap = isElbowArrow(arrow)
|
||||
? 0
|
||||
: getBindingGap({ strokeWidth: targetStrokeWidth }, arrow);
|
||||
|
||||
// How far back along the arrow the bound side has to sit for the tip to
|
||||
// stay exactly where it is.
|
||||
//
|
||||
// `updateBoundPoint` resolves an orbit binding by intersecting the arrow's
|
||||
// own line with the target's outline grown by `gap`; it does not step along
|
||||
// the bound side's normal. Offsetting the anchor perpendicular to the side
|
||||
// would therefore leave the tip `gap * tan(angle)` off to one side on a
|
||||
// diagonal arrow. Walking back along the arrow instead keeps the anchor both
|
||||
// `gap` clear of the tip and collinear with the arrow, so the intersection
|
||||
// lands back on the tip itself.
|
||||
//
|
||||
// `heading` is the dominant axis of the arrow direction, so it is never more
|
||||
// than 45° away from it and the divisor stays >= cos(45°).
|
||||
const awayFromTip = vectorNormalize(vectorFromPoint(neighbor, endpoint));
|
||||
const alongHeading = Math.abs(
|
||||
vectorDot(awayFromTip, vector(heading[0], heading[1])),
|
||||
);
|
||||
const anchor = pointFromVector(
|
||||
vectorScale(awayFromTip, -gap / alongHeading),
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (compareHeading(heading, HEADING_RIGHT)) {
|
||||
// text sits to the right of the tip -> bind its left side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0, 0.5]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.LEFT as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (compareHeading(heading, HEADING_LEFT)) {
|
||||
// text sits to the left of the tip -> bind its right side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([1, 0.5]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.RIGHT as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (compareHeading(heading, HEADING_DOWN)) {
|
||||
// text sits below the tip -> bind its top side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0.5, 0]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.CENTER as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.TOP as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
// text sits above the tip -> bind its bottom side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0.5, 1]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.CENTER as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.BOTTOM as VerticalAlign,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an arrow endpoint is bound to this text — i.e. the text serves as an
|
||||
* arrow-endpoint label. Checked on the arrows' own bindings: the text's
|
||||
* `boundElements` only says an arrow relates to it, while the binding on the
|
||||
* arrow is the authoritative side of the relationship.
|
||||
*/
|
||||
export const isEndpointBoundText = (
|
||||
text: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean =>
|
||||
!!text.boundElements?.some(({ id, type }) => {
|
||||
if (type !== "arrow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arrow = elementsMap.get(id);
|
||||
|
||||
return (
|
||||
isArrowElement(arrow) &&
|
||||
(arrow.startBinding?.elementId === text.id ||
|
||||
arrow.endBinding?.elementId === text.id)
|
||||
);
|
||||
});
|
||||
|
||||
/** the anchor a text bound to an arrow endpoint grows away from */
|
||||
export const getEndpointBoundTextDragAnchor = (
|
||||
newElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const anchorRatio = getTextAnchorRatios(newElement).x;
|
||||
|
||||
return {
|
||||
anchorRatio,
|
||||
anchorX: newElement.x + newElement.width * anchorRatio,
|
||||
};
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeleted,
|
||||
FixedPointBinding,
|
||||
NonDeletedSceneElementsMap,
|
||||
PointsPositionUpdates,
|
||||
} from "../types";
|
||||
@@ -111,10 +112,17 @@ const focusPointUpdate = (
|
||||
) => {
|
||||
const pointUpdates = new Map();
|
||||
|
||||
const originalAdjacentBinding =
|
||||
appState.selectedLinearElement?.initialState
|
||||
.arrowOtherEndpointInitialBinding;
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
|
||||
let currentBinding = arrow[bindingField];
|
||||
let adjacentBinding = arrow[adjacentBindingField];
|
||||
let adjacentBinding =
|
||||
originalAdjacentBinding?.mode === "orbit" &&
|
||||
arrow[adjacentBindingField]?.mode === "inside"
|
||||
? originalAdjacentBinding
|
||||
: arrow[adjacentBindingField];
|
||||
|
||||
// Update the dragged focus point related end
|
||||
if (currentBinding && bindableElement) {
|
||||
@@ -164,7 +172,7 @@ const focusPointUpdate = (
|
||||
// Same shape bound on both ends
|
||||
const boundToSameElementAfterUpdate =
|
||||
bindableElement && adjacentBinding.elementId === bindableElement.id;
|
||||
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
|
||||
if (boundToSameElementAfterUpdate) {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "inside",
|
||||
@@ -340,6 +348,7 @@ export const handleFocusPointPointerDown = (
|
||||
): {
|
||||
hitFocusPoint: "start" | "end" | null;
|
||||
pointerOffset: { x: number; y: number };
|
||||
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
|
||||
} => {
|
||||
const pointerPos = pointFrom(
|
||||
pointerDownState.origin.x,
|
||||
@@ -377,6 +386,7 @@ export const handleFocusPointPointerDown = (
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
arrowOtherEndpointInitialBinding: arrow.endBinding,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -412,6 +422,7 @@ export const handleFocusPointPointerDown = (
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
arrowOtherEndpointInitialBinding: arrow.startBinding,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -420,6 +431,7 @@ export const handleFocusPointPointerDown = (
|
||||
return {
|
||||
hitFocusPoint: null,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -484,6 +496,10 @@ export const handleFocusPointPointerUp = (
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleFocusPointHover = (
|
||||
|
||||
+120
-43
@@ -13,6 +13,7 @@ import {
|
||||
pointDistance,
|
||||
pointDistanceSq,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
@@ -122,7 +123,9 @@ export const FOCUS_POINT_SIZE = 10 / 1.5;
|
||||
const MIN_BINDABLE_SIZE = 1;
|
||||
|
||||
export const getBindingGap = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
// only the stroke width is needed, so the gap can also be computed for a
|
||||
// bind target that doesn't exist yet (see `getTextBindingForArrowEndpoint`)
|
||||
bindTarget: Pick<ExcalidrawBindableElement, "strokeWidth">,
|
||||
opts: Pick<ExcalidrawArrowElement, "elbowed">,
|
||||
): number => {
|
||||
return (
|
||||
@@ -758,6 +761,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
threshold: 0,
|
||||
overrideShouldTestInside: true,
|
||||
});
|
||||
const otherEndpointInitialBinding =
|
||||
appState.selectedLinearElement?.initialState
|
||||
.arrowOtherEndpointInitialBinding;
|
||||
|
||||
// Handle outside-outside binding to the same element
|
||||
if (
|
||||
@@ -773,10 +779,18 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
? globalPoint
|
||||
: // NOTE: Can only affect the start point because new arrows always drag the end point
|
||||
opts?.newArrow
|
||||
? getGridPoint(
|
||||
appState.selectedLinearElement!.initialState.origin![0],
|
||||
appState.selectedLinearElement!.initialState.origin![1],
|
||||
opts.gridSize as NullableGridSize,
|
||||
? pointFromArray<GlobalPoint>(
|
||||
getGridPoint(
|
||||
appState.selectedLinearElement!.initialState.origin![0],
|
||||
appState.selectedLinearElement!.initialState.origin![1],
|
||||
opts.gridSize as NullableGridSize,
|
||||
),
|
||||
)!
|
||||
: otherBindableElement
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
otherBinding.fixedPoint,
|
||||
otherBindableElement,
|
||||
elementsMap,
|
||||
)
|
||||
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
@@ -789,6 +803,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
element: hit,
|
||||
focusPoint: endDragged
|
||||
? globalPoint
|
||||
: otherBindableElement
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
otherBinding.fixedPoint,
|
||||
otherBindableElement,
|
||||
elementsMap,
|
||||
)
|
||||
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
-1,
|
||||
@@ -878,36 +898,53 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
threshold: maxBindingDistance_simple(appState.zoom),
|
||||
overrideShouldTestInside: true,
|
||||
});
|
||||
const otherPointWasInsideAtStart =
|
||||
otherEndpointInitialBinding?.mode === "inside";
|
||||
const otherNeverOverride = opts?.newArrow
|
||||
? appState.selectedLinearElement?.initialState.arrowStartIsInside
|
||||
: otherBinding?.mode === "inside";
|
||||
const other: BindingStrategy = !otherNeverOverride
|
||||
? otherBindableElement &&
|
||||
: otherBinding?.mode === "inside" && otherPointWasInsideAtStart;
|
||||
|
||||
let other: BindingStrategy = { mode: undefined };
|
||||
if (!otherNeverOverride) {
|
||||
if (
|
||||
otherBinding?.mode === "inside" &&
|
||||
!otherPointWasInsideAtStart &&
|
||||
otherBindableElement &&
|
||||
!opts?.newArrow
|
||||
) {
|
||||
other = {
|
||||
mode: "orbit",
|
||||
element: otherBindableElement,
|
||||
focusPoint: otherFocusPoint || otherEndpoint,
|
||||
};
|
||||
} else if (
|
||||
otherBindableElement &&
|
||||
!otherFocusPointIsInElement &&
|
||||
!pointIsCloseToOtherElement &&
|
||||
appState.selectedLinearElement?.initialState.altFocusPoint
|
||||
? {
|
||||
mode: "orbit",
|
||||
element: otherBindableElement,
|
||||
focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
|
||||
}
|
||||
: opts?.angleLocked && otherBindableElement
|
||||
? {
|
||||
mode: "orbit",
|
||||
element: otherBindableElement,
|
||||
focusPoint:
|
||||
projectFixedPointOntoDiagonal(
|
||||
arrow,
|
||||
otherEndpoint,
|
||||
otherBindableElement,
|
||||
startDragged ? "end" : "start",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
false,
|
||||
) || otherEndpoint,
|
||||
}
|
||||
: { mode: undefined }
|
||||
: { mode: undefined };
|
||||
) {
|
||||
other = {
|
||||
mode: "orbit",
|
||||
element: otherBindableElement,
|
||||
focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
|
||||
};
|
||||
} else if (opts?.angleLocked && otherBindableElement) {
|
||||
other = {
|
||||
mode: "orbit",
|
||||
element: otherBindableElement,
|
||||
focusPoint:
|
||||
projectFixedPointOntoDiagonal(
|
||||
arrow,
|
||||
otherEndpoint,
|
||||
otherBindableElement,
|
||||
startDragged ? "end" : "start",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isMidpointSnappingEnabled,
|
||||
) || otherEndpoint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: startDragged ? current : other,
|
||||
@@ -1063,6 +1100,32 @@ export const bindOrUnbindBindingElements = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a binding onto the arrow and records the arrow on the bind target,
|
||||
* keeping the two sides of the relationship in step.
|
||||
*/
|
||||
const applyBinding = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
binding: FixedPointBinding,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
): void => {
|
||||
scene.mutateElement(arrow, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
|
||||
});
|
||||
|
||||
const boundElementsMap = arrayToMap(bindableElement.boundElements || []);
|
||||
if (!boundElementsMap.has(arrow.id)) {
|
||||
scene.mutateElement(bindableElement, {
|
||||
boundElements: (bindableElement.boundElements || []).concat({
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const bindBindingElement = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
hoveredElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
@@ -1104,19 +1167,7 @@ export const bindBindingElement = (
|
||||
};
|
||||
}
|
||||
|
||||
scene.mutateElement(arrow, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
|
||||
});
|
||||
|
||||
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
|
||||
if (!boundElementsMap.has(arrow.id)) {
|
||||
scene.mutateElement(hoveredElement, {
|
||||
boundElements: (hoveredElement.boundElements || []).concat({
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
}),
|
||||
});
|
||||
}
|
||||
applyBinding(arrow, hoveredElement, binding, startOrEnd, scene);
|
||||
};
|
||||
|
||||
export const unbindBindingElement = (
|
||||
@@ -3119,3 +3170,29 @@ export const getBindingSideMidPoint = (
|
||||
const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => {
|
||||
return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Binds an arrow endpoint to an explicit fixed point on a bindable element,
|
||||
* bypassing the "derive the ratio from where the endpoint currently is"
|
||||
* strategy used when dragging. Needed when the binding target is placed to fit
|
||||
* the arrow rather than the other way round.
|
||||
*/
|
||||
export const bindBindingElementToFixedPoint = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPoint: FixedPoint,
|
||||
scene: Scene,
|
||||
): void => {
|
||||
applyBinding(
|
||||
arrow,
|
||||
bindableElement,
|
||||
{
|
||||
elementId: bindableElement.id,
|
||||
fixedPoint: normalizeFixedPoint(fixedPoint),
|
||||
mode: "orbit",
|
||||
},
|
||||
startOrEnd,
|
||||
scene,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -420,6 +420,7 @@ const _isRectanguloidElement = (
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -467,6 +467,7 @@ export const intersectElementWithLineSegment = (
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
|
||||
@@ -2,22 +2,31 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "stickynote" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "line" ||
|
||||
type === "freedraw";
|
||||
type === "freedraw" ||
|
||||
type === "autoshape" ||
|
||||
// tool-only type; makes the `G` background shortcut work for bucket fill
|
||||
type === "bucketfill";
|
||||
|
||||
export const hasFillStyle = (type: ElementOrToolType) =>
|
||||
hasBackground(type) && type !== "stickynote";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "stickynote" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "text" ||
|
||||
type === "embeddable";
|
||||
type === "embeddable" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
@@ -27,7 +36,8 @@ export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
type === "line" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
@@ -36,7 +46,11 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
type === "line" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasRoughness = (type: ElementOrToolType) =>
|
||||
hasStrokeStyle(type) || type === "stickynote";
|
||||
|
||||
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
|
||||
|
||||
@@ -46,6 +60,7 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "embeddable" ||
|
||||
type === "line" ||
|
||||
type === "diamond" ||
|
||||
type === "stickynote" ||
|
||||
type === "image";
|
||||
|
||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
import {
|
||||
getBoundsFromPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element/bounds";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newLinearElement,
|
||||
} from "@excalidraw/element/newElement";
|
||||
import {
|
||||
ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
|
||||
getStrokeWidthByKey,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
convexHull,
|
||||
distanceToLineSegment,
|
||||
elongation,
|
||||
kurtosis,
|
||||
lineSegment,
|
||||
orientPrincipalAxes,
|
||||
pointFrom,
|
||||
polygonArea,
|
||||
principalAxes,
|
||||
principalCoords,
|
||||
skewness,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { App, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ElementsMap,
|
||||
ExcalidrawNonSelectionElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawLineElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { getFrameLikeElements } from "./frame";
|
||||
import {
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "./typeChecks";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LAST_POINTS?: readonly GlobalPoint[];
|
||||
}
|
||||
}
|
||||
|
||||
// A moment-based shape recognizer.
|
||||
//
|
||||
// NOTE: Rectangle vs. diamond is deliberately *not* rotation invariant. A 45
|
||||
// rectangle = diamond.
|
||||
|
||||
type NonDeletedRecognizedShapeElement = NonDeleted<
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawLineElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
>;
|
||||
|
||||
type Shape = NonDeletedRecognizedShapeElement["type"];
|
||||
|
||||
interface ShapeRecognitionResult<P extends LocalPoint | GlobalPoint> {
|
||||
// The type of the recognized shape, or "freedraw" if no good match was found
|
||||
type: Shape;
|
||||
|
||||
// The original input points (not simplified)
|
||||
points: readonly P[];
|
||||
|
||||
// Bounding box of the original input points, used for positioning the
|
||||
// converted shape
|
||||
boundingBox: Bounds;
|
||||
}
|
||||
|
||||
// Number of points every stroke is resampled to before feature extraction.
|
||||
const RESAMPLE_N = 64;
|
||||
|
||||
// Minimum apparent (on-screen) size of a stroke's larger bounding-box
|
||||
// dimension, in pixels, for recognition to run — below this the stroke reads
|
||||
// as an accidental scribble. Compared against `sceneSize * zoom` so the same
|
||||
// physical gesture behaves the same at every zoom level.
|
||||
//
|
||||
// Maximum not required. Runtime is O(point count), points come in at roughly
|
||||
// one per frame.
|
||||
const RECOGNITION_MIN_SCREEN_SIZE = 25;
|
||||
|
||||
// A stroke whose endpoints are farther apart than this fraction of its own
|
||||
// path length is treated as open (a line or an arrow) rather than closed (a
|
||||
// rectangle, diamond or ellipse).
|
||||
const CLOSED_GAP_MAX_RATIO = 0.15;
|
||||
|
||||
// Maximum elongation for an open stroke to count as straight.
|
||||
const LINEAR_MAX_ELONGATION = 0.25;
|
||||
|
||||
// Fraction of the start→tip distance around the tip inside which an arrowhead
|
||||
// may live: points there are exempt from the shaft straightness check.
|
||||
const ARROWHEAD_ZONE_RATIO = 0.5;
|
||||
|
||||
// Maximum distance any point outside the arrowhead zone may stray from the
|
||||
// start→tip chord, as a fraction of the chord length. Loose enough for a lazy
|
||||
// bow or a small squiggle, tight enough to reject elbows, arcs and sawtooths
|
||||
// that only *statistically* look straight (their point-cloud elongation
|
||||
// passes, their geometry doesn't).
|
||||
const LINEAR_MAX_SHAFT_DEVIATION = 0.15;
|
||||
|
||||
// Minimum |skew| along the major axis for an open, straight stroke to be an
|
||||
// arrow rather than a plain line. Arrowhead add "mass" -> skews that way.
|
||||
const ARROW_MIN_SKEW = 0.3;
|
||||
|
||||
// Maximum feature-space distance to a shape prototype, in units of the
|
||||
// per-feature tolerances in CLOSED_SHAPE_PROTOTYPES. Beyond this the stroke is
|
||||
// not enough like any known shape and stays freedraw.
|
||||
const CLOSED_SHAPE_MAX_DISTANCE = 1.5;
|
||||
|
||||
// Half-width, in resampled points, of the chord window used to measure local
|
||||
// turning along the stroke. Wide enough to smooth pen wobble, narrow enough
|
||||
// that adjacent corners of a small quadrilateral stay separate peaks.
|
||||
const TURN_WINDOW = 3;
|
||||
|
||||
// =============================================================================
|
||||
// Feature extraction
|
||||
// =============================================================================
|
||||
|
||||
// Resample `pts` to exactly `n` evenly-spaced points along the stroke path.
|
||||
function resample<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
n: number,
|
||||
): P[] {
|
||||
let totalLen = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
totalLen += Math.hypot(
|
||||
pts[i][0] - pts[i - 1][0],
|
||||
pts[i][1] - pts[i - 1][1],
|
||||
);
|
||||
}
|
||||
|
||||
const interval = totalLen / (n - 1);
|
||||
let accumulated = 0;
|
||||
const result: P[] = [pts[0]];
|
||||
let prev = pts[0];
|
||||
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const curr = pts[i];
|
||||
const segLen = Math.hypot(curr[0] - prev[0], curr[1] - prev[1]);
|
||||
if (accumulated + segLen >= interval) {
|
||||
// Insert interpolated points within this segment
|
||||
let remaining = interval - accumulated;
|
||||
while (remaining <= segLen + 1e-10) {
|
||||
const t = remaining / segLen;
|
||||
const newPt: P = [
|
||||
prev[0] + t * (curr[0] - prev[0]),
|
||||
prev[1] + t * (curr[1] - prev[1]),
|
||||
] as P;
|
||||
result.push(newPt);
|
||||
if (result.length === n) {
|
||||
return result;
|
||||
}
|
||||
prev = newPt;
|
||||
accumulated = 0;
|
||||
remaining += interval;
|
||||
}
|
||||
accumulated = segLen - (remaining - interval);
|
||||
} else {
|
||||
accumulated += segLen;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
// Fill any remaining slots with the last point (rounding errors)
|
||||
while (result.length < n) {
|
||||
result.push(pts[pts.length - 1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Statistical description of a stroke, in terms that don't depend on where,
|
||||
// how big, or (except where noted) how rotated it was drawn.
|
||||
interface StrokeFeatures {
|
||||
// Straight-line distance between the endpoints over the stroke's own path
|
||||
// length. ~0 when the stroke returns to its start.
|
||||
gapRatio: number;
|
||||
|
||||
// Point cloud elongation along the major axis.
|
||||
elongation: number;
|
||||
|
||||
// Skew of the points projected onto the major axis. Signed so that it is
|
||||
// <= 0 by construction.
|
||||
majorSkew: number;
|
||||
|
||||
// Convex hull area over axis-aligned bounding box area, in the screen
|
||||
// frame: ~1 for a rectangle, ~PI/4 for an ellipse, ~1/2 for a diamond.
|
||||
hullFillRatio: number;
|
||||
|
||||
// Share of the stroke's total turning concentrated in its 4 strongest
|
||||
// corner loci: ~1 for any quadrilateral — however tapered or slanted, all
|
||||
// turning happens at the corners — vs. ~0.5 for an ellipse, whose turning
|
||||
// is spread along the whole outline. This is what tells a sloppy,
|
||||
// trapezoid-ish rectangle (hull fill ≈ PI/4, same as an ellipse's) from an
|
||||
// actual ellipse: sharp corners.
|
||||
cornerTurnShare: number;
|
||||
|
||||
// kurtosis along x times kurtosis along y, in the screen frame. The two
|
||||
// factors each vary with the aspect ratio, but their product barely does:
|
||||
// ~1.83 for any rectangle, 2.25 for any ellipse, 3.24 for any diamond.
|
||||
kurtosisProduct: number;
|
||||
|
||||
// How far the stroke strays from the straight chord between its start and
|
||||
// its farthest point ("tip"), as a fraction of that chord's length — points
|
||||
// within ARROWHEAD_ZONE_RATIO of the tip are exempt, so an arrowhead
|
||||
// doesn't count as straying. ~0 for a line or arrow however wobbly, large
|
||||
// for elbows, arcs and sawtooths, whose point-cloud statistics alone can
|
||||
// still look line-like.
|
||||
shaftDeviationRatio: number;
|
||||
}
|
||||
|
||||
// See StrokeFeatures.shaftDeviationRatio.
|
||||
function shaftDeviationRatio<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
): number {
|
||||
const start = pts[0];
|
||||
let tip = start;
|
||||
let tipDistance = 0;
|
||||
for (const point of pts) {
|
||||
const distance = Math.hypot(point[0] - start[0], point[1] - start[1]);
|
||||
if (distance > tipDistance) {
|
||||
tipDistance = distance;
|
||||
tip = point;
|
||||
}
|
||||
}
|
||||
if (tipDistance === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const chord = lineSegment(start, tip);
|
||||
let maxDeviation = 0;
|
||||
for (const point of pts) {
|
||||
const tipDist = Math.hypot(point[0] - tip[0], point[1] - tip[1]);
|
||||
if (tipDist <= ARROWHEAD_ZONE_RATIO * tipDistance) {
|
||||
continue;
|
||||
}
|
||||
maxDeviation = Math.max(maxDeviation, distanceToLineSegment(point, chord));
|
||||
}
|
||||
return maxDeviation / tipDistance;
|
||||
}
|
||||
|
||||
// The turn angle at every resampled point, measured between the incoming and
|
||||
// outgoing chords `TURN_WINDOW` points away. The stroke is deliberately NOT
|
||||
// treated as a closed loop: wrapping around would let the closure artifacts of
|
||||
// a hand-drawn outline (overshooting or undershooting the starting point)
|
||||
// fabricate a sharp corner that was never drawn.
|
||||
function windowedTurns<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
): number[] {
|
||||
const turns: number[] = [];
|
||||
for (let i = TURN_WINDOW; i < pts.length - TURN_WINDOW; i++) {
|
||||
const [ax, ay] = pts[i - TURN_WINDOW];
|
||||
const [bx, by] = pts[i];
|
||||
const [cx, cy] = pts[i + TURN_WINDOW];
|
||||
const v1x = bx - ax;
|
||||
const v1y = by - ay;
|
||||
const v2x = cx - bx;
|
||||
const v2y = cy - by;
|
||||
turns.push(
|
||||
Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y)),
|
||||
);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
// See StrokeFeatures.cornerTurnShare. Greedily picks the 4 strongest turn
|
||||
// peaks, each absorbing its ±TURN_WINDOW neighborhood so one physical corner
|
||||
// (whose turn smears across the window) counts once.
|
||||
function cornerTurnShare<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
): number {
|
||||
const turns = windowedTurns(pts);
|
||||
const total = turns.reduce((sum, turn) => sum + turn, 0);
|
||||
if (total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const taken = new Array<boolean>(turns.length).fill(false);
|
||||
let top4 = 0;
|
||||
for (let corner = 0; corner < 4; corner++) {
|
||||
let peak = -1;
|
||||
let peakTurn = 0;
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
if (!taken[i] && turns[i] > peakTurn) {
|
||||
peakTurn = turns[i];
|
||||
peak = i;
|
||||
}
|
||||
}
|
||||
if (peak < 0) {
|
||||
break;
|
||||
}
|
||||
for (let i = peak - TURN_WINDOW; i <= peak + TURN_WINDOW; i++) {
|
||||
if (i >= 0 && i < turns.length && !taken[i]) {
|
||||
top4 += turns[i];
|
||||
taken[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return top4 / total;
|
||||
}
|
||||
|
||||
function extractFeatures<P extends LocalPoint | GlobalPoint>(
|
||||
points: readonly P[],
|
||||
): StrokeFeatures {
|
||||
// Resample first: features are moments of the point set, so they would
|
||||
// otherwise be biased by pointer speed, which crowds samples wherever the
|
||||
// user drew slowly.
|
||||
const pts = resample(points, RESAMPLE_N);
|
||||
|
||||
let pathLength = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
pathLength += Math.hypot(
|
||||
pts[i][0] - pts[i - 1][0],
|
||||
pts[i][1] - pts[i - 1][1],
|
||||
);
|
||||
}
|
||||
const gap = Math.hypot(
|
||||
pts[pts.length - 1][0] - pts[0][0],
|
||||
pts[pts.length - 1][1] - pts[0][1],
|
||||
);
|
||||
|
||||
const axes = orientPrincipalAxes(pts, principalAxes(pts));
|
||||
const majorProjection = principalCoords(pts, axes).map(([u]) => u);
|
||||
|
||||
const hull = convexHull(pts);
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(pts);
|
||||
const boxArea = (maxX - minX) * (maxY - minY);
|
||||
|
||||
return {
|
||||
gapRatio: pathLength > 0 ? gap / pathLength : 0,
|
||||
elongation: elongation(axes),
|
||||
majorSkew: skewness(majorProjection),
|
||||
hullFillRatio: boxArea > 0 ? polygonArea(hull) / boxArea : 0,
|
||||
cornerTurnShare: cornerTurnShare(pts),
|
||||
kurtosisProduct:
|
||||
kurtosis(pts.map(([x]) => x)) * kurtosis(pts.map(([, y]) => y)),
|
||||
shaftDeviationRatio: shaftDeviationRatio(pts),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Classification
|
||||
// =============================================================================
|
||||
|
||||
const CLOSED_SHAPE_PROTOTYPES: readonly {
|
||||
type: Shape;
|
||||
hullFillRatio: number;
|
||||
cornerTurnShare: number;
|
||||
kurtosisProduct: number;
|
||||
}[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
hullFillRatio: 1,
|
||||
cornerTurnShare: 0.95,
|
||||
kurtosisProduct: 1.83,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
hullFillRatio: 0.5,
|
||||
cornerTurnShare: 0.95,
|
||||
kurtosisProduct: 3.24,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
hullFillRatio: Math.PI / 4,
|
||||
cornerTurnShare: 0.55,
|
||||
kurtosisProduct: 2.25,
|
||||
},
|
||||
];
|
||||
|
||||
// Hull fill no longer has to separate rectangles from ellipses on its own
|
||||
// (cornerTurnShare carries that), so it can afford the slack that lets a
|
||||
// tapered, hand-drawn quadrilateral (fill ≈ 0.8) still read as a rectangle.
|
||||
const HULL_FILL_RATIO_TOLERANCE = 0.2;
|
||||
const CORNER_TURN_SHARE_TOLERANCE = 0.2;
|
||||
const KURTOSIS_PRODUCT_TOLERANCE = 0.7;
|
||||
|
||||
// Pick the closed shape whose prototype the stroke's features sit closest to,
|
||||
// or freedraw when even the nearest one is too far away.
|
||||
function classifyClosedStroke(features: StrokeFeatures): Shape {
|
||||
let best: Shape = "freedraw";
|
||||
let bestDistance = CLOSED_SHAPE_MAX_DISTANCE;
|
||||
|
||||
for (const prototype of CLOSED_SHAPE_PROTOTYPES) {
|
||||
const distance = Math.hypot(
|
||||
(features.hullFillRatio - prototype.hullFillRatio) /
|
||||
HULL_FILL_RATIO_TOLERANCE,
|
||||
(features.cornerTurnShare - prototype.cornerTurnShare) /
|
||||
CORNER_TURN_SHARE_TOLERANCE,
|
||||
(features.kurtosisProduct - prototype.kurtosisProduct) /
|
||||
KURTOSIS_PRODUCT_TOLERANCE,
|
||||
);
|
||||
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = prototype.type;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// An open stroke is a line or an arrow, provided it is straight enough; the
|
||||
// arrowhead is what makes the point distribution lopsided.
|
||||
function classifyOpenStroke(features: StrokeFeatures): Shape {
|
||||
if (
|
||||
features.elongation > LINEAR_MAX_ELONGATION ||
|
||||
features.shaftDeviationRatio > LINEAR_MAX_SHAFT_DEVIATION
|
||||
) {
|
||||
return "freedraw";
|
||||
}
|
||||
return Math.abs(features.majorSkew) >= ARROW_MIN_SKEW ? "arrow" : "line";
|
||||
}
|
||||
|
||||
function classify(features: StrokeFeatures): Shape {
|
||||
return features.gapRatio > CLOSED_GAP_MAX_RATIO
|
||||
? classifyOpenStroke(features)
|
||||
: classifyClosedStroke(features);
|
||||
}
|
||||
|
||||
// Arrow endpoint selection
|
||||
|
||||
// When an arrow is recognized, the original input's last point may not
|
||||
// correspond to the actual arrow tip. The tip must lie on the bounding-box
|
||||
// perimeter and is the perimeter point farthest from the start (first input
|
||||
// point). We find the closest original input point to that ideal tip.
|
||||
function getArrowEndpoint<P extends LocalPoint | GlobalPoint>(
|
||||
points: readonly P[],
|
||||
boundingBox: Bounds,
|
||||
startPoint: P,
|
||||
): P {
|
||||
const [minX, minY, maxX, maxY] = boundingBox;
|
||||
const w = maxX - minX;
|
||||
const h = maxY - minY;
|
||||
|
||||
// Degenerate bounding box – fall back to last point
|
||||
if (w === 0 && h === 0) {
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
// Perimeter corners / edge points to check (4 corners + 4 edge midpoints
|
||||
// give good coverage of the perimeter).
|
||||
const perimeterPoints: LocalPoint[] = [
|
||||
pointFrom(minX, minY),
|
||||
pointFrom((minX + maxX) / 2, minY),
|
||||
pointFrom(maxX, minY),
|
||||
pointFrom(maxX, (minY + maxY) / 2),
|
||||
pointFrom(maxX, maxY),
|
||||
pointFrom((minX + maxX) / 2, maxY),
|
||||
pointFrom(minX, maxY),
|
||||
pointFrom(minX, (minY + maxY) / 2),
|
||||
];
|
||||
|
||||
// Ideal endpoint = perimeter point farthest from start.
|
||||
let idealDist = -1;
|
||||
let idealEndpoint: LocalPoint = pointFrom(maxX, maxY);
|
||||
for (const pp of perimeterPoints) {
|
||||
const d = Math.hypot(pp[0] - startPoint[0], pp[1] - startPoint[1]);
|
||||
if (d > idealDist) {
|
||||
idealDist = d;
|
||||
idealEndpoint = pp;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the original input point closest to the ideal endpoint.
|
||||
let bestDist = Infinity;
|
||||
let bestPoint: P = points[points.length - 1];
|
||||
for (const pt of points) {
|
||||
const d = Math.hypot(pt[0] - idealEndpoint[0], pt[1] - idealEndpoint[1]);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestPoint = pt;
|
||||
}
|
||||
}
|
||||
return bestPoint;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public API
|
||||
// =============================================================================
|
||||
|
||||
// Recognizes common shapes from free-draw input points
|
||||
export const recognizeShape = <P extends LocalPoint | GlobalPoint>(
|
||||
points: P[],
|
||||
previousElement: ExcalidrawElement | null,
|
||||
zoom: number = 1,
|
||||
): ShapeRecognitionResult<P> => {
|
||||
// DEBUG (will be removed lated)
|
||||
if (typeof window !== "undefined") {
|
||||
window.LAST_POINTS = points.map(([x, y]) => pointFrom<GlobalPoint>(x, y));
|
||||
}
|
||||
|
||||
const boundingBox = getBoundsFromPoints(points);
|
||||
|
||||
const [minX, minY, maxX, maxY] = boundingBox;
|
||||
const maxDim = Math.max(maxX - minX, maxY - minY);
|
||||
if (points.length < 3 || maxDim * zoom < RECOGNITION_MIN_SCREEN_SIZE) {
|
||||
return { type: "freedraw", points, boundingBox };
|
||||
}
|
||||
|
||||
const recognized = classify(extractFeatures(points));
|
||||
const type =
|
||||
previousElement?.type === "arrow" && recognized !== "arrow"
|
||||
? "freedraw"
|
||||
: recognized;
|
||||
|
||||
return { type, points, boundingBox };
|
||||
};
|
||||
|
||||
// Converts a freedraw element to the detected shape.
|
||||
//
|
||||
// `frames` lets callers pass the scene's cached frame-like elements (e.g.
|
||||
// scene.getNonDeletedFramesLikes()) to avoid spreading and re-filtering the
|
||||
// whole element map on every invocation; when omitted it is derived from
|
||||
// `elementsMap`.
|
||||
export const convertToShape = (
|
||||
points: GlobalPoint[],
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
previousElement: ExcalidrawElement | null,
|
||||
frames?: readonly ExcalidrawFrameLikeElement[],
|
||||
): NonDeletedRecognizedShapeElement | undefined => {
|
||||
const recognizedShape = recognizeShape(
|
||||
points,
|
||||
previousElement,
|
||||
appState.zoom.value,
|
||||
);
|
||||
|
||||
const boundingBox = recognizedShape.boundingBox;
|
||||
const [minX, minY, maxX, maxY] = boundingBox;
|
||||
|
||||
const frameLikeElements =
|
||||
frames ?? getFrameLikeElements([...elementsMap.values()]);
|
||||
const frameId =
|
||||
frameLikeElements.find((frame) => {
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
return fx1 <= minX && fy1 <= minY && fx2 >= maxX && fy2 >= maxY;
|
||||
})?.id ?? null;
|
||||
|
||||
const roundness =
|
||||
appState.currentItemRoundness === "round"
|
||||
? {
|
||||
type: isUsingAdaptiveRadius(recognizedShape.type)
|
||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null;
|
||||
|
||||
switch (recognizedShape.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
return newElement({
|
||||
type: recognizedShape.type,
|
||||
x: recognizedShape.boundingBox[0],
|
||||
y: recognizedShape.boundingBox[1],
|
||||
width: recognizedShape.boundingBox[2] - recognizedShape.boundingBox[0],
|
||||
height: recognizedShape.boundingBox[3] - recognizedShape.boundingBox[1],
|
||||
groupIds: [],
|
||||
angle: 0 as Radians,
|
||||
frameId,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
}) as NonDeletedRecognizedShapeElement;
|
||||
}
|
||||
case "arrow": {
|
||||
const [arrowX, arrowY] = recognizedShape.points[0];
|
||||
|
||||
const [globalEndX, globalEndY] = getArrowEndpoint(
|
||||
recognizedShape.points,
|
||||
recognizedShape.boundingBox,
|
||||
recognizedShape.points[0],
|
||||
);
|
||||
const endPoint: LocalPoint = pointFrom<LocalPoint>(
|
||||
globalEndX - arrowX,
|
||||
globalEndY - arrowY,
|
||||
);
|
||||
const arrowLen = Math.hypot(
|
||||
globalEndX - recognizedShape.points[0][0],
|
||||
globalEndY - recognizedShape.points[0][1],
|
||||
);
|
||||
if (arrowLen < 60) {
|
||||
const tempElement = newLinearElement({
|
||||
type: "line",
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(
|
||||
recognizedShape.points[0][0] - arrowX,
|
||||
recognizedShape.points[0][1] - arrowY,
|
||||
),
|
||||
endPoint,
|
||||
],
|
||||
groupIds: [],
|
||||
frameId,
|
||||
locked: false,
|
||||
angle: 0 as Radians,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
});
|
||||
|
||||
const normalized =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
|
||||
|
||||
return newLinearElement({
|
||||
...tempElement,
|
||||
...normalized,
|
||||
}) as NonDeletedRecognizedShapeElement;
|
||||
}
|
||||
const tempElement = newArrowElement({
|
||||
type: "arrow",
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
startArrowhead: appState.currentItemStartArrowhead,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(
|
||||
recognizedShape.points[0][0] - arrowX,
|
||||
recognizedShape.points[0][1] - arrowY,
|
||||
),
|
||||
endPoint,
|
||||
],
|
||||
groupIds: [],
|
||||
frameId,
|
||||
locked: false,
|
||||
angle: 0 as Radians,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
});
|
||||
|
||||
const normalized =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
|
||||
|
||||
return newArrowElement({
|
||||
...tempElement,
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
case "line": {
|
||||
const [lineX, lineY] = recognizedShape.points[0];
|
||||
const endPoint =
|
||||
recognizedShape.points[recognizedShape.points.length - 1];
|
||||
const tempElement = newLinearElement({
|
||||
type: recognizedShape.type,
|
||||
x: lineX,
|
||||
y: lineY,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(
|
||||
recognizedShape.points[0][0] - lineX,
|
||||
recognizedShape.points[0][1] - lineY,
|
||||
),
|
||||
pointFrom<LocalPoint>(endPoint[0] - lineX, endPoint[1] - lineY),
|
||||
],
|
||||
groupIds: [],
|
||||
frameId,
|
||||
locked: false,
|
||||
angle: 0 as Radians,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
});
|
||||
|
||||
const normalized =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
|
||||
|
||||
return newLinearElement({
|
||||
...tempElement,
|
||||
...normalized,
|
||||
}) as NonDeletedRecognizedShapeElement;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Whether two drawShape preview elements are visually identical, so a redundant
|
||||
// preview recompute on pointermove can skip setState (avoiding a no-op React
|
||||
// re-render and roughjs shape regeneration).
|
||||
const isDrawShapePreviewEqual = (
|
||||
a: NonDeletedExcalidrawElement | null,
|
||||
b: NonDeletedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (
|
||||
!a ||
|
||||
a.type !== b.type ||
|
||||
a.x !== b.x ||
|
||||
a.y !== b.y ||
|
||||
a.width !== b.width ||
|
||||
a.height !== b.height ||
|
||||
a.angle !== b.angle
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isLinearElement(a) && isLinearElement(b)) {
|
||||
if (a.points.length !== b.points.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.points.length; i++) {
|
||||
if (
|
||||
a.points[i][0] !== b.points[i][0] ||
|
||||
a.points[i][1] !== b.points[i][1]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Append to the trail and recompute the shape preview. This runs inside
|
||||
// withBatchedUpdatesThrottled (throttleRAF), so it fires at most once per
|
||||
// animation frame regardless of raw pointermove rate.
|
||||
export const convertToShapeHandlePointerMoveFromPointerDown = (
|
||||
app: App,
|
||||
pointerCoords: { x: number; y: number },
|
||||
) => {
|
||||
if (app.state.activeTool.type === "autoshape") {
|
||||
app.drawShape.trail.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
|
||||
const drawShapeTrailPoints = app.drawShape.trail.getCurrentPoints();
|
||||
// note: size gate inside recognizeShape
|
||||
if (drawShapeTrailPoints.length >= 3) {
|
||||
const shapePreview = convertToShape(
|
||||
drawShapeTrailPoints,
|
||||
app.state,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.state.newElement,
|
||||
app.scene.getNonDeletedFramesLikes(),
|
||||
) as ExcalidrawNonSelectionElement | undefined;
|
||||
|
||||
// Lines get no live preview — nearly every stroke reads as a line at
|
||||
// some early point, so previewing them is mostly flicker. Releasing
|
||||
// still commits the line: finalize() re-recognizes from the trail when
|
||||
// no preview exists.
|
||||
if (shapePreview && !isLineElement(shapePreview)) {
|
||||
const prevPreview = app.state.newElement;
|
||||
const nextPreview = {
|
||||
...shapePreview,
|
||||
// Keep a stable id across the gesture while the recognized type
|
||||
// is unchanged, so the preview element's identity doesn't churn
|
||||
// frame to frame.
|
||||
id:
|
||||
prevPreview?.type === shapePreview.type
|
||||
? prevPreview.id
|
||||
: shapePreview.id,
|
||||
created:
|
||||
prevPreview?.type === shapePreview.type
|
||||
? prevPreview.created
|
||||
: shapePreview.created,
|
||||
seed: 1,
|
||||
opacity: ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
|
||||
isDeleted: false as const,
|
||||
} as NonDeletedRecognizedShapeElement;
|
||||
|
||||
if (!isDrawShapePreviewEqual(prevPreview, nextPreview)) {
|
||||
app.setState({ newElement: nextPreview });
|
||||
}
|
||||
} else if (app.state.newElement !== null) {
|
||||
app.setState({ newElement: null });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
+205
-37
@@ -36,6 +36,8 @@ import type {
|
||||
|
||||
import { getObservedAppState } from "./store";
|
||||
|
||||
import { CURRENT_SCHEMA_VERSION, migrateElementPartial } from "./versioning";
|
||||
|
||||
import {
|
||||
BoundElement,
|
||||
BindableElement,
|
||||
@@ -47,6 +49,7 @@ import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
@@ -1084,9 +1087,33 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
return delta;
|
||||
}
|
||||
|
||||
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
|
||||
public static restore(
|
||||
elementsDeltaDTO: DTO<ElementsDelta>,
|
||||
schemaVersion: number = CURRENT_SCHEMA_VERSION,
|
||||
): ElementsDelta {
|
||||
const { added, removed, updated } = elementsDeltaDTO;
|
||||
return ElementsDelta.create(added, removed, updated);
|
||||
return ElementsDelta.create(
|
||||
ElementsDelta.migratePartialDeltas(added, schemaVersion),
|
||||
ElementsDelta.migratePartialDeltas(removed, schemaVersion),
|
||||
ElementsDelta.migratePartialDeltas(updated, schemaVersion),
|
||||
);
|
||||
}
|
||||
|
||||
private static migratePartialDeltas(
|
||||
deltas: Record<string, Delta<ElementPartial>>,
|
||||
fromVersion: number,
|
||||
): Record<string, Delta<ElementPartial>> {
|
||||
if (fromVersion >= CURRENT_SCHEMA_VERSION) {
|
||||
return deltas;
|
||||
}
|
||||
|
||||
return Object.entries(deltas).reduce((acc, [id, delta]) => {
|
||||
acc[id] = Delta.create(
|
||||
migrateElementPartial(delta.deleted, fromVersion),
|
||||
migrateElementPartial(delta.inserted, fromVersion),
|
||||
);
|
||||
return acc;
|
||||
}, {} as Record<string, Delta<ElementPartial>>);
|
||||
}
|
||||
|
||||
private static satisfiesAddition = ({
|
||||
@@ -1442,6 +1469,48 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Whether this delta results in a visible change is decided from the
|
||||
// *applied* result, not from the property patches: binding repair and
|
||||
// layout (text bounding boxes, bound arrows) run below and can move
|
||||
// elements the delta never named, and an `isDeleted` flip or a
|
||||
// `boundElements` edit is not visible by itself — deleting a note's
|
||||
// empty label is the canonical case. History relies on this answer to
|
||||
// skip such entries on undo/redo (see `History`'s apply loop).
|
||||
//
|
||||
// `idsToCheck` is every element whose "before" and "after" get
|
||||
// compared: the changed elements plus what layout can move for them —
|
||||
// a text's container, and everything in `boundElements` (a container's
|
||||
// label and arrows). The set is walked as it grows, so an arrow added
|
||||
// for its container brings in its own label in turn. The dev/test
|
||||
// assertion after the redraw verifies the set is complete.
|
||||
const idsToCheck = new Set(changedElements.keys());
|
||||
// Unchanged elements are shared between `elements` and `nextElements`,
|
||||
// and layout mutates them in place — their "before" must be a shallow
|
||||
// copy taken now. Changed elements already have a fresh instance in
|
||||
// `nextElements`, so the instance in `elements` is their "before" as
|
||||
// is. Only `idsToCheck` is saved; the whole scene is never copied.
|
||||
const previousElements = new Map<string, OrderedExcalidrawElement>();
|
||||
for (const id of idsToCheck) {
|
||||
const previous = elements.get(id);
|
||||
const next = nextElements.get(id);
|
||||
for (const element of [previous, next]) {
|
||||
if (element) {
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
idsToCheck.add(element.containerId);
|
||||
}
|
||||
for (const binding of element.boundElements ?? []) {
|
||||
idsToCheck.add(binding.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous) {
|
||||
previousElements.set(
|
||||
id,
|
||||
previous === next ? { ...previous } : previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
@@ -1450,8 +1519,53 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
flags,
|
||||
);
|
||||
|
||||
// Guard for `idsToCheck`: layout bumps the version of everything it
|
||||
// mutates, so an element whose version moved during the redraw but was
|
||||
// never collected is a layout dependency the expansion above does not
|
||||
// know about — and a visible change that undo would silently skip.
|
||||
// Fail loudly instead (dev and tests only; this is a scene-wide pass).
|
||||
const versionsBeforeRedraw =
|
||||
isTestEnv() || isDevEnv()
|
||||
? new Map(
|
||||
Array.from(nextElements, ([id, element]) => [
|
||||
id,
|
||||
element.version,
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
|
||||
if (versionsBeforeRedraw) {
|
||||
for (const [id, element] of nextElements) {
|
||||
if (
|
||||
versionsBeforeRedraw.get(id) !== element.version &&
|
||||
!idsToCheck.has(id)
|
||||
) {
|
||||
throw new Error(
|
||||
`Redrawn element "${id}" is missing from idsToCheck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
for (const id of idsToCheck) {
|
||||
if (
|
||||
ElementsDelta.checkForVisibleDifference(
|
||||
previousElements.get(id),
|
||||
nextElements.get(id),
|
||||
previousElements,
|
||||
nextElements,
|
||||
)
|
||||
) {
|
||||
flags.containsVisibleDifference = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
flags.containsVisibleDifference = true;
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
e,
|
||||
@@ -1460,9 +1574,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return [nextElements, flags.containsVisibleDifference];
|
||||
}
|
||||
|
||||
return [nextElements, flags.containsVisibleDifference];
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
@@ -1625,12 +1739,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
if (element) {
|
||||
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
|
||||
// (whether it is *visible* is decided in `applyTo`, from the applied result)
|
||||
flags.containsZindexDifference = true;
|
||||
|
||||
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
} else {
|
||||
// not in elements, not in snapshot? element might have been added remotely!
|
||||
element = newElementWith(
|
||||
@@ -1687,17 +1797,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||
element,
|
||||
rest,
|
||||
);
|
||||
|
||||
flags.containsVisibleDifference = containsVisibleDifference;
|
||||
}
|
||||
|
||||
// visibility is not decided here: `applyTo` compares the applied result
|
||||
// once layout has run, so that its effects count too
|
||||
if (!flags.containsZindexDifference) {
|
||||
flags.containsZindexDifference =
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
@@ -1707,29 +1808,96 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for visible changes regardless of whether they were removed, added or updated.
|
||||
* Whether an element looks different after the delta — and the layout that
|
||||
* followed it — was applied. The input to history's "skip entries with no
|
||||
* visible change" loop.
|
||||
*
|
||||
* Both sides are first reduced to what actually paints:
|
||||
* - a missing or deleted element paints nothing;
|
||||
* - an empty text element paints nothing either — unless it carries arrow
|
||||
* bindings (an arrow may end at it) or sits on an arrow (an empty label
|
||||
* still cuts the arrow's gap);
|
||||
* so an empty label's deletion or restoration compares as nothing against
|
||||
* nothing. Two painting elements are then compared property by property:
|
||||
* - `version`, `versionNonce`, `updated`, `created` are metadata, and
|
||||
* `index` is covered by the separate z-order check (`reorderElements`);
|
||||
* - `boundElements` is compared as `type:id` references with references to
|
||||
* empty labels dropped, so adding or removing an empty label's reference
|
||||
* is not visible on its own, and binding repair that rebuilt equal
|
||||
* objects is not either;
|
||||
* - everything else counts. Nested arrays (`points`, binding fixed points)
|
||||
* compare by reference, which can only over-report a change — the safe
|
||||
* direction, where undo stops one entry early.
|
||||
*
|
||||
* `previousElements` holds only `idsToCheck` (see `applyTo`); the label
|
||||
* lookups it serves are for elements whose labels are in that set.
|
||||
*/
|
||||
private static checkForVisibleDifference(
|
||||
element: OrderedExcalidrawElement,
|
||||
partial: ElementPartial,
|
||||
previous: OrderedExcalidrawElement | undefined,
|
||||
next: OrderedExcalidrawElement | undefined,
|
||||
previousElements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
nextElements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
if (element.isDeleted && partial.isDeleted !== false) {
|
||||
// when it's deleted and partial is not false, it cannot end up with a visible change
|
||||
return false;
|
||||
const visibleElement = (
|
||||
element: OrderedExcalidrawElement | undefined,
|
||||
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) => {
|
||||
if (!element || element.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isTextElement(element) &&
|
||||
!element.text.trim() &&
|
||||
!element.boundElements?.length &&
|
||||
!isArrowElement(elements.get(element.containerId ?? ""))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
const before = visibleElement(previous, previousElements);
|
||||
const after = visibleElement(next, nextElements);
|
||||
if (!before || !after) {
|
||||
return before !== after;
|
||||
}
|
||||
|
||||
if (element.isDeleted && partial.isDeleted === false) {
|
||||
// when we add an element, it results in a visible change
|
||||
return true;
|
||||
}
|
||||
const visibleBindings = (
|
||||
element: OrderedExcalidrawElement,
|
||||
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) =>
|
||||
(element.boundElements ?? [])
|
||||
.filter((binding) => {
|
||||
const label = elements.get(binding.id);
|
||||
return (
|
||||
binding.type !== "text" ||
|
||||
isArrowElement(element) ||
|
||||
!label ||
|
||||
!isTextElement(label) ||
|
||||
!!label.text.trim()
|
||||
);
|
||||
})
|
||||
.map(({ id, type }) => `${type}:${id}`);
|
||||
|
||||
if (element.isDeleted === false && partial.isDeleted) {
|
||||
// when we remove an element, it results in a visible change
|
||||
return true;
|
||||
}
|
||||
|
||||
// check for any difference on a visible element
|
||||
return Delta.isRightDifferent(element, partial);
|
||||
return Delta.getDifferences(before, after).some((key) => {
|
||||
switch (key) {
|
||||
// Ordering is checked separately; the remaining fields are metadata.
|
||||
case "index":
|
||||
case "version":
|
||||
case "versionNonce":
|
||||
case "updated":
|
||||
case "created":
|
||||
return false;
|
||||
case "boundElements":
|
||||
return !isShallowEqual(
|
||||
visibleBindings(before, previousElements),
|
||||
visibleBindings(after, nextElements),
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,7 @@ export const distanceToElement = (
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
@@ -75,9 +76,7 @@ const distanceToRectanguloidElement = (
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...corners
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
...corners.map((a) => curvePointDistance(a, rotatedPoint)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -104,9 +103,7 @@ const distanceToDiamondElement = (
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...curves
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
...curves.map((a) => curvePointDistance(a, rotatedPoint)),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement, ExcalidrawTextElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@@ -228,6 +228,72 @@ export const getDragOffsetXY = (
|
||||
return [x - x1, y - y1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sizes a text element as it is dragged out.
|
||||
*
|
||||
* A dragged text pins one point and grows away from it; `anchorRatio` says
|
||||
* where along the box that point sits — 0 for its left edge, 1 for its right,
|
||||
* 0.5 for its centre.
|
||||
*
|
||||
* A free text pins the point the drag started from and takes the ratio from
|
||||
* the drag direction, so it can be pulled either way. A text bound to an arrow
|
||||
* endpoint instead pins whatever the binding placed it against and takes the
|
||||
* ratio from its alignment — which is also what keeps it from growing back
|
||||
* over the arrow, since dragging that way makes no progress rather than
|
||||
* flipping the box around.
|
||||
*/
|
||||
export const dragNewTextElement = ({
|
||||
newElement,
|
||||
anchorX,
|
||||
anchorRatio,
|
||||
pointerX,
|
||||
nextY,
|
||||
zoom,
|
||||
scene,
|
||||
informMutation = true,
|
||||
}: {
|
||||
newElement: ExcalidrawTextElement;
|
||||
anchorX: number;
|
||||
/** 0 = anchored by its left edge, 1 = by its right, 0.5 = by its centre */
|
||||
anchorRatio: number;
|
||||
pointerX: number;
|
||||
/** free text re-tops itself to the drag origin; a bound one must not move */
|
||||
nextY?: number;
|
||||
zoom: NormalizedZoomValue;
|
||||
scene: Scene;
|
||||
informMutation?: boolean;
|
||||
}) => {
|
||||
const offset = pointerX - anchorX;
|
||||
|
||||
// how far the pointer has travelled away from the anchor along the direction
|
||||
// the box may grow — negative once it heads back the other way
|
||||
const reach =
|
||||
anchorRatio === 0 ? offset : anchorRatio === 1 ? -offset : Math.abs(offset);
|
||||
|
||||
const width = Math.max(
|
||||
// a centred box grows on both sides, so it widens at twice the reach
|
||||
anchorRatio === 0.5 ? reach * 2 : reach,
|
||||
getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
),
|
||||
);
|
||||
|
||||
scene.mutateElement(
|
||||
newElement,
|
||||
{
|
||||
x: anchorX - width * anchorRatio,
|
||||
...(nextY === undefined ? {} : { y: nextY }),
|
||||
width,
|
||||
...(reach > TEXT_AUTOWRAP_THRESHOLD / zoom ? { autoResize: false } : {}),
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
);
|
||||
};
|
||||
|
||||
export const dragNewElement = ({
|
||||
newElement,
|
||||
elementType,
|
||||
@@ -293,6 +359,22 @@ export const dragNewElement = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
// a text is only ever sized horizontally — its height follows the wrapped
|
||||
// content — so it grows away from the point the drag started at
|
||||
dragNewTextElement({
|
||||
newElement,
|
||||
anchorX: originX + (originOffset?.x ?? 0),
|
||||
anchorRatio: shouldResizeFromCenter ? 0.5 : x < originX ? 1 : 0,
|
||||
pointerX: x,
|
||||
nextY: originY + (originOffset?.y ?? 0),
|
||||
zoom,
|
||||
scene,
|
||||
informMutation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
@@ -303,31 +385,6 @@ export const dragNewElement = ({
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
let textAutoResize = null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
height = newElement.height;
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
);
|
||||
width = Math.max(width, minWidth);
|
||||
|
||||
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||
textAutoResize = {
|
||||
autoResize: false,
|
||||
};
|
||||
}
|
||||
|
||||
newY = originY;
|
||||
if (shouldResizeFromCenter) {
|
||||
newX = originX - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
let imageInitialDimension = null;
|
||||
if (isImageElement(newElement)) {
|
||||
@@ -344,7 +401,6 @@ export const dragNewElement = ({
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
|
||||
@@ -75,6 +75,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
|
||||
copy.id = randomId();
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.created = copy.updated;
|
||||
if (randomizeSeed) {
|
||||
copy.seed = randomInteger();
|
||||
bumpVersion(copy);
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newStickyNoteElement,
|
||||
} from "./newElement";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
@@ -230,8 +234,7 @@ const cloneFlowchartNode = (
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const node = newElement({
|
||||
type: template.type,
|
||||
const commonNodeProps = {
|
||||
x,
|
||||
y,
|
||||
width: template.width,
|
||||
@@ -244,7 +247,19 @@ const cloneFlowchartNode = (
|
||||
opacity: template.opacity,
|
||||
fillStyle: template.fillStyle,
|
||||
strokeStyle: template.strokeStyle,
|
||||
});
|
||||
};
|
||||
|
||||
const node =
|
||||
template.type === "stickynote"
|
||||
? newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
...commonNodeProps,
|
||||
baseHeight: template.baseHeight,
|
||||
})
|
||||
: newElement({
|
||||
type: template.type,
|
||||
...commonNodeProps,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(node),
|
||||
@@ -254,7 +269,7 @@ const cloneFlowchartNode = (
|
||||
return node;
|
||||
};
|
||||
|
||||
const addNewNodes = (
|
||||
export const addNewNodes = (
|
||||
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
} from "@excalidraw/fractional-indexing";
|
||||
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { getBoundTextElementId } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
@@ -65,6 +67,8 @@ export const validateFractionalIndices = (
|
||||
const stringifyElement = (element: ExcalidrawElement | void) =>
|
||||
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
|
||||
|
||||
const elementsMap = includeBoundTextValidation ? arrayToMap(elements) : null;
|
||||
|
||||
const indices = elements.map((x) => x.index);
|
||||
for (const [i, index] of indices.entries()) {
|
||||
const predecessorIndex = indices[i - 1];
|
||||
@@ -81,11 +85,23 @@ export const validateFractionalIndices = (
|
||||
}
|
||||
|
||||
// disabled by default, as we don't fix it
|
||||
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
|
||||
if (
|
||||
includeBoundTextValidation &&
|
||||
elementsMap &&
|
||||
hasBoundTextElement(elements[i]) &&
|
||||
isNonDeletedElement(elements[i])
|
||||
) {
|
||||
const container = elements[i];
|
||||
const text = getBoundTextElement(container, arrayToMap(elements));
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
const text = boundTextElementId
|
||||
? elementsMap.get(boundTextElementId)
|
||||
: null;
|
||||
|
||||
if (text && text.index! <= container.index!) {
|
||||
if (
|
||||
text &&
|
||||
isNonDeletedElement(text) &&
|
||||
text.index! <= container.index!
|
||||
) {
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
|
||||
text,
|
||||
|
||||
@@ -57,8 +57,10 @@ export const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
): readonly NonDeleted<T>[] => elements.filter(isNonDeletedElement);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./arrowEndpointText";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./bucketFill";
|
||||
export * from "./collision";
|
||||
export * from "./comparisons";
|
||||
export * from "./containerCache";
|
||||
@@ -92,6 +94,7 @@ export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./stickyNote";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
@@ -99,6 +102,8 @@ export * from "./transform";
|
||||
export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./versioning";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
export * from "./convertToShape";
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curveLengthAtParameter,
|
||||
curvePointAtLength,
|
||||
lineSegmentClosestParameter,
|
||||
lineSegmentPointAt,
|
||||
curveClosestParameter,
|
||||
clamp,
|
||||
bezierEquation,
|
||||
isCurve,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -30,8 +37,11 @@ import {
|
||||
moveArrowAboveBindable,
|
||||
projectFixedPointOntoDiagonal,
|
||||
type Store,
|
||||
getLinearElementPathSegments,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LinearPathSegment } from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
@@ -122,6 +132,15 @@ type PointMoveOtherUpdates = {
|
||||
suggestedBinding?: AppState["suggestedBinding"] | null;
|
||||
};
|
||||
|
||||
const BoundTextPositionCache = new WeakMap<
|
||||
ExcalidrawTextElementWithContainer,
|
||||
{
|
||||
containerVersion: ExcalidrawElement["version"];
|
||||
textVersion: ExcalidrawElement["version"];
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
>();
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -141,6 +160,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
arrowStartIsInside: boolean;
|
||||
altFocusPoint: Readonly<GlobalPoint> | null;
|
||||
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
|
||||
}>;
|
||||
|
||||
/** whether you're dragging a point */
|
||||
@@ -193,6 +213,7 @@ export class LinearElementEditor {
|
||||
added: false,
|
||||
},
|
||||
arrowStartIsInside: false,
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
altFocusPoint: null,
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
@@ -777,6 +798,7 @@ export class LinearElementEditor {
|
||||
...editingLinearElement.initialState,
|
||||
origin: null,
|
||||
arrowStartIsInside: false,
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1028,6 +1050,8 @@ export class LinearElementEditor {
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
/** the pointer went down on the arrow's label, grabbing it for a drag */
|
||||
hitBoundText: boolean;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
} {
|
||||
const appState = app.state;
|
||||
@@ -1036,6 +1060,7 @@ export class LinearElementEditor {
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
hitElement: null,
|
||||
hitBoundText: false,
|
||||
linearElementEditor: null,
|
||||
};
|
||||
|
||||
@@ -1098,6 +1123,7 @@ export class LinearElementEditor {
|
||||
!!app.state.newElement &&
|
||||
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
|
||||
altFocusPoint: null,
|
||||
arrowOtherEndpointInitialBinding: element.startBinding,
|
||||
},
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
@@ -1108,16 +1134,51 @@ export class LinearElementEditor {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
const pointIndexUnderCursor = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint) {
|
||||
const clickedPointIsHandle = LinearElementEditor.isPointHandle(
|
||||
element,
|
||||
pointIndexUnderCursor,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
let boundTextGrabOffset: { x: number; y: number } | null = null;
|
||||
if (
|
||||
!clickedPointIsHandle &&
|
||||
// the segment midpoint knob keeps precedence over the label sitting
|
||||
// on top of it, so a labeled arrow can still be bent at its middle
|
||||
!segmentMidpoint &&
|
||||
boundTextElement &&
|
||||
isArrowElement(element) &&
|
||||
app.arrowText.isBoundTextGrabbable(
|
||||
element,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
)
|
||||
) {
|
||||
const { x: textX, y: textY } =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
boundTextGrabOffset = {
|
||||
x: scenePointer.x - (textX + boundTextElement.width / 2),
|
||||
y: scenePointer.y - (textY + boundTextElement.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
// when the label grab wins (possible over a non-interactive elbow route
|
||||
// point), the click must not double as a point click
|
||||
const clickedPointIndex = boundTextGrabOffset ? -1 : pointIndexUnderCursor;
|
||||
ret.hitBoundText = !!boundTextGrabOffset;
|
||||
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint || boundTextGrabOffset) {
|
||||
ret.hitElement = element;
|
||||
}
|
||||
|
||||
@@ -1160,6 +1221,9 @@ export class LinearElementEditor {
|
||||
!!app.state.newElement &&
|
||||
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
|
||||
altFocusPoint: null,
|
||||
arrowOtherEndpointInitialBinding: nextSelectedPointsIndices?.includes(0)
|
||||
? element.endBinding
|
||||
: element.startBinding,
|
||||
},
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
@@ -1167,7 +1231,7 @@ export class LinearElementEditor {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
: boundTextGrabOffset ?? { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
return ret;
|
||||
@@ -1348,6 +1412,19 @@ export class LinearElementEditor {
|
||||
return pointFrom(x - element.x, y - element.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the point at `index` is an interactive handle. Elbow arrows only
|
||||
* expose their endpoints; their intermediate route points are not draggable.
|
||||
*/
|
||||
static isPointHandle(element: ExcalidrawLinearElement, index: number) {
|
||||
return (
|
||||
index >= 0 &&
|
||||
(!isElbowArrow(element) ||
|
||||
index === 0 ||
|
||||
index === element.points.length - 1)
|
||||
);
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -1852,41 +1929,200 @@ export class LinearElementEditor {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The point a bound text label is centered on — the middle of the linear
|
||||
* element, whether that's an actual point or the midpoint of the middle
|
||||
* segment.
|
||||
*/
|
||||
static getBoundTextElementCenter = (
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
if (element.points.length % 2 === 1) {
|
||||
const index = Math.floor(element.points.length / 2);
|
||||
return LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
const index = element.points.length / 2 - 1;
|
||||
return LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
static handleBoundTextDragging(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
): LinearElementEditor | null {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
);
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (!element || !isArrowElement(element) || !boundTextElement) {
|
||||
return null;
|
||||
}
|
||||
const pointerGlobalPoint = pointFrom<GlobalPoint>(
|
||||
pointerX - linearElementEditor.pointerOffset.x,
|
||||
pointerY - linearElementEditor.pointerOffset.y,
|
||||
);
|
||||
const { segments, lengths, prefixSums, totalLength } =
|
||||
getLinearElementPathMetrics(element, elementsMap);
|
||||
if (segments.length === 0 || totalLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestDistance = Infinity;
|
||||
let bestSegmentIndex = 0;
|
||||
let bestParameter = 0;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const { t, distance } = pathSegmentClosestParameter(
|
||||
segments[i],
|
||||
pointerGlobalPoint,
|
||||
);
|
||||
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSegmentIndex = i;
|
||||
bestParameter = t;
|
||||
}
|
||||
}
|
||||
|
||||
const lengthWithinSegment = pathSegmentLengthAtParameter(
|
||||
segments[bestSegmentIndex],
|
||||
bestParameter,
|
||||
lengths[bestSegmentIndex],
|
||||
);
|
||||
|
||||
const labelPosition = clamp(
|
||||
(prefixSums[bestSegmentIndex] + lengthWithinSegment) / totalLength,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const pathPoint = LinearElementEditor.getPointAtPathParameter(
|
||||
element,
|
||||
labelPosition,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pathPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.mutateElement(boundTextElement, {
|
||||
labelPosition,
|
||||
x: pathPoint[0] - boundTextElement.width / 2,
|
||||
y: pathPoint[1] - boundTextElement.height / 2,
|
||||
});
|
||||
|
||||
return {
|
||||
...linearElementEditor,
|
||||
isDragging: true,
|
||||
};
|
||||
}
|
||||
|
||||
static getPointAtPathParameter(
|
||||
container: ExcalidrawLinearElement,
|
||||
pathParameter: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint | null {
|
||||
if (!Number.isFinite(pathParameter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { segments, lengths, prefixSums, totalLength } =
|
||||
getLinearElementPathMetrics(container, elementsMap);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetLength = clamp(pathParameter, 0, 1) * totalLength;
|
||||
|
||||
// the first segment whose end lies at or beyond the target; the last one
|
||||
// catches a target that floating point pushed past the total length
|
||||
const found = segments.findIndex(
|
||||
(_, i) => targetLength <= prefixSums[i + 1],
|
||||
);
|
||||
const i = found === -1 ? segments.length - 1 : found;
|
||||
const lengthFraction =
|
||||
lengths[i] === 0
|
||||
? 0
|
||||
: clamp((targetLength - prefixSums[i]) / lengths[i], 0, 1);
|
||||
|
||||
return pathSegmentPointAtLength(segments[i], lengthFraction, lengths[i]);
|
||||
}
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
): { x: number; y: number } => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
// derived on every hit test, bounds query and render of the label, so
|
||||
// memoize on the pair of versions the result depends on
|
||||
const cached = BoundTextPositionCache.get(boundTextElement);
|
||||
if (
|
||||
cached &&
|
||||
cached.containerVersion === element.version &&
|
||||
cached.textVersion === boundTextElement.version
|
||||
) {
|
||||
return cached.position;
|
||||
}
|
||||
|
||||
const position = LinearElementEditor.computeBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length < 2) {
|
||||
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
|
||||
|
||||
BoundTextPositionCache.set(boundTextElement, {
|
||||
containerVersion: element.version,
|
||||
textVersion: boundTextElement.version,
|
||||
position,
|
||||
});
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
private static computeBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
): { x: number; y: number } => {
|
||||
if (element.points.length < 2) {
|
||||
return { x: boundTextElement.x, y: boundTextElement.y };
|
||||
}
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if (element.points.length % 2 === 1) {
|
||||
const index = Math.floor(element.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (isArrowElement(element) && boundTextElement.labelPosition != null) {
|
||||
const pathPoint = LinearElementEditor.getPointAtPathParameter(
|
||||
element,
|
||||
element.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
x = midPoint[0] - boundTextElement.width / 2;
|
||||
y = midPoint[1] - boundTextElement.height / 2;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
boundTextElement.labelPosition,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
if (pathPoint) {
|
||||
return {
|
||||
x: pathPoint[0] - boundTextElement.width / 2,
|
||||
y: pathPoint[1] - boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { x, y };
|
||||
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
x: center[0] - boundTextElement.width / 2,
|
||||
y: center[1] - boundTextElement.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
static getMinMaxXYWithBoundText = (
|
||||
@@ -2423,22 +2659,21 @@ const pointDraggingUpdates = (
|
||||
)! as ExcalidrawBindableElement)
|
||||
: null;
|
||||
|
||||
const endLocalPoint = startIsDraggingOverEndElement
|
||||
? nextArrow.points[nextArrow.points.length - 1]
|
||||
: endIsDraggingOverStartElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
? nextArrow.points[0]
|
||||
: endBindable
|
||||
? updateBoundPoint(
|
||||
nextArrow,
|
||||
"endBinding",
|
||||
nextArrow.endBinding,
|
||||
endBindable,
|
||||
elementsMap,
|
||||
endIsDragged,
|
||||
) || nextArrow.points[nextArrow.points.length - 1]
|
||||
: nextArrow.points[nextArrow.points.length - 1];
|
||||
const endLocalPoint =
|
||||
endIsDraggingOverStartElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
? nextArrow.points[0]
|
||||
: endBindable
|
||||
? updateBoundPoint(
|
||||
nextArrow,
|
||||
"endBinding",
|
||||
nextArrow.endBinding,
|
||||
endBindable,
|
||||
elementsMap,
|
||||
endIsDragged,
|
||||
) || nextArrow.points[nextArrow.points.length - 1]
|
||||
: nextArrow.points[nextArrow.points.length - 1];
|
||||
|
||||
// We need to keep the simulated next arrow up-to-date, because
|
||||
// updateBoundPoint looks at the opposite point
|
||||
@@ -2472,13 +2707,11 @@ const pointDraggingUpdates = (
|
||||
: nextArrow.points[0];
|
||||
|
||||
const endChanged =
|
||||
!startIsDraggingOverEndElement &&
|
||||
!(
|
||||
endIsDraggingOverStartElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
) &&
|
||||
!!endBindable;
|
||||
) && !!endBindable;
|
||||
const startChanged =
|
||||
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
|
||||
|
||||
@@ -2519,3 +2752,93 @@ const determineCustomLinearAngle = (
|
||||
draggedPoint: LocalPoint,
|
||||
) =>
|
||||
Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]);
|
||||
|
||||
type LinearElementPathMetrics = {
|
||||
segments: LinearPathSegment[];
|
||||
lengths: number[];
|
||||
prefixSums: number[];
|
||||
totalLength: number;
|
||||
};
|
||||
|
||||
const LinearElementPathMetricsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; metrics: LinearElementPathMetrics }
|
||||
>();
|
||||
|
||||
function getLinearElementPathMetrics(
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearElementPathMetrics {
|
||||
const cached = LinearElementPathMetricsCache.get(element);
|
||||
|
||||
if (cached) {
|
||||
if (cached.version === element.version) {
|
||||
return cached.metrics;
|
||||
}
|
||||
|
||||
LinearElementPathMetricsCache.delete(element);
|
||||
}
|
||||
|
||||
const segments = getLinearElementPathSegments(element, elementsMap);
|
||||
const lengths = segments.map(pathSegmentLength);
|
||||
const prefixSums = new Array<number>(lengths.length + 1);
|
||||
|
||||
prefixSums[0] = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
prefixSums[i + 1] = prefixSums[i] + lengths[i];
|
||||
}
|
||||
|
||||
const metrics: LinearElementPathMetrics = {
|
||||
segments,
|
||||
lengths,
|
||||
prefixSums,
|
||||
totalLength: prefixSums[lengths.length],
|
||||
};
|
||||
|
||||
LinearElementPathMetricsCache.set(element, {
|
||||
version: element.version,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const pathSegmentLength = (segment: LinearPathSegment): number =>
|
||||
isCurve(segment)
|
||||
? curveLength(segment)
|
||||
: pointDistance(segment[0], segment[1]);
|
||||
|
||||
/**
|
||||
* The segment's parameter `t` (`0..1`) at the point closest to `point`, and
|
||||
* how far that point is from `point`.
|
||||
*/
|
||||
const pathSegmentClosestParameter = (
|
||||
segment: LinearPathSegment,
|
||||
point: GlobalPoint,
|
||||
): { t: number; distance: number } => {
|
||||
if (isCurve(segment)) {
|
||||
const t = curveClosestParameter(segment, point);
|
||||
return { t, distance: pointDistance(point, bezierEquation(segment, t)) };
|
||||
}
|
||||
|
||||
const t = lineSegmentClosestParameter(point, segment);
|
||||
return { t, distance: pointDistance(point, lineSegmentPointAt(segment, t)) };
|
||||
};
|
||||
|
||||
/** arc length from the segment's start to its parameter `t` */
|
||||
const pathSegmentLengthAtParameter = (
|
||||
segment: LinearPathSegment,
|
||||
t: number,
|
||||
segmentLength: number,
|
||||
): number =>
|
||||
isCurve(segment) ? curveLengthAtParameter(segment, t) : t * segmentLength;
|
||||
|
||||
/** point at `fraction` (`0..1`) of the segment's arc length */
|
||||
const pathSegmentPointAtLength = (
|
||||
segment: LinearPathSegment,
|
||||
fraction: number,
|
||||
segmentLength: number,
|
||||
): GlobalPoint =>
|
||||
isCurve(segment)
|
||||
? curvePointAtLength(segment, fraction, segmentLength)
|
||||
: lineSegmentPointAt(segment, fraction);
|
||||
|
||||
@@ -21,9 +21,12 @@ import type {
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
// `created` is lifetime metadata: it is set once when an element instance is
|
||||
// constructed (or duplicated) and never updated, so it is not part of an
|
||||
// element update. An explicit repair must construct a new element object.
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "updated"
|
||||
"id" | "updated" | "created"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
VERTICAL_ALIGN,
|
||||
@@ -22,6 +24,10 @@ import {
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import {
|
||||
normalizeStickyNoteBackgroundColor,
|
||||
normalizeStickyNoteStrokeColor,
|
||||
} from "./stickyNote";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
@@ -49,6 +55,7 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -74,6 +81,7 @@ export type ElementConstructorOpts = MarkOptional<
|
||||
| "locked"
|
||||
| "opacity"
|
||||
| "customData"
|
||||
| "created"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
@@ -123,6 +131,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = getUpdatedTimestamp();
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<
|
||||
ExcalidrawGenericElement,
|
||||
@@ -151,7 +161,9 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
updated: timestamp,
|
||||
// Preserve explicit null when reconstructing a legacy element with its id.
|
||||
created: rest.created === undefined ? timestamp : rest.created,
|
||||
link,
|
||||
locked,
|
||||
customData: rest.customData,
|
||||
@@ -166,6 +178,70 @@ export const newElement = (
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
/**
|
||||
* Style invariants of a sticky note: never-transparent colors, solid fill.
|
||||
* Applied by the constructor and by every normalization pass. Returns the
|
||||
* same object when nothing needs fixing.
|
||||
*/
|
||||
export const normalizeStickyNoteStyle = <T extends ExcalidrawStickyNoteElement>(
|
||||
element: T,
|
||||
): T => {
|
||||
return newElementWith(element as ExcalidrawStickyNoteElement, {
|
||||
backgroundColor: normalizeStickyNoteBackgroundColor(
|
||||
element.backgroundColor,
|
||||
),
|
||||
strokeColor: normalizeStickyNoteStrokeColor(element.strokeColor),
|
||||
fillStyle: "solid",
|
||||
}) as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Geometry invariants of a *finalized* sticky note: minimum size and
|
||||
* `baseHeight ≤ height`. Deliberately not part of the constructor — a
|
||||
* pointer-down draft starts at 0×0 like every other tool and is previewed at
|
||||
* its true dragged size; pointer-up, restore, the skeleton path and the
|
||||
* action post-passes enforce this.
|
||||
*/
|
||||
export const normalizeStickyNoteGeometry = <
|
||||
T extends ExcalidrawStickyNoteElement,
|
||||
>(
|
||||
element: T,
|
||||
): T => {
|
||||
const width = Math.max(element.width, STICKY_NOTE_MIN_SIZE);
|
||||
const baseHeight = Math.max(
|
||||
element.baseHeight || element.height || DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
);
|
||||
|
||||
return newElementWith(element as ExcalidrawStickyNoteElement, {
|
||||
width,
|
||||
height: Math.max(element.height, baseHeight),
|
||||
baseHeight,
|
||||
}) as T;
|
||||
};
|
||||
|
||||
/** all sticky note invariants (style + finalized geometry) */
|
||||
export const normalizeStickyNote = <T extends ExcalidrawStickyNoteElement>(
|
||||
element: T,
|
||||
): T => {
|
||||
return normalizeStickyNoteGeometry(normalizeStickyNoteStyle(element));
|
||||
};
|
||||
|
||||
export const newStickyNoteElement = (
|
||||
opts: {
|
||||
type: "stickynote";
|
||||
baseHeight?: number;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawStickyNoteElement> => {
|
||||
const base = _newElementBase<ExcalidrawStickyNoteElement>("stickynote", opts);
|
||||
|
||||
// no size inflation here (see `normalizeStickyNoteGeometry`)
|
||||
return normalizeStickyNoteStyle({
|
||||
...base,
|
||||
baseHeight: opts.baseHeight ?? base.height,
|
||||
});
|
||||
};
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
@@ -218,6 +294,25 @@ export const newMagicFrameElement = (
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* The point of the text box its alignment pins, as ratios of width/height.
|
||||
*
|
||||
* This is the point that stays put as the text grows — see the sides passed to
|
||||
* `adjustXYWithRotation` in `getAdjustedDimensions`.
|
||||
*/
|
||||
export const getTextAnchorRatios = (opts: {
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||
}) => ({
|
||||
x: opts.textAlign === "center" ? 0.5 : opts.textAlign === "right" ? 1 : 0,
|
||||
y:
|
||||
opts.verticalAlign === VERTICAL_ALIGN.MIDDLE
|
||||
? 0.5
|
||||
: opts.verticalAlign === VERTICAL_ALIGN.BOTTOM
|
||||
? 1
|
||||
: 0,
|
||||
});
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
@@ -229,14 +324,11 @@ const getTextElementPositionOffsets = (
|
||||
height: number;
|
||||
},
|
||||
) => {
|
||||
const ratios = getTextAnchorRatios(opts);
|
||||
|
||||
return {
|
||||
x:
|
||||
opts.textAlign === "center"
|
||||
? metrics.width / 2
|
||||
: opts.textAlign === "right"
|
||||
? metrics.width
|
||||
: 0,
|
||||
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||
x: metrics.width * ratios.x,
|
||||
y: metrics.height * ratios.y,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -251,6 +343,8 @@ export const newTextElement = (
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
labelPosition?: ExcalidrawTextElement["labelPosition"];
|
||||
baseFontSize?: ExcalidrawTextElement["baseFontSize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
@@ -273,6 +367,7 @@ export const newTextElement = (
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
baseFontSize: opts.baseFontSize ?? null,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
@@ -284,6 +379,7 @@ export const newTextElement = (
|
||||
originalText: opts.originalText ?? text,
|
||||
autoResize: opts.autoResize ?? true,
|
||||
lineHeight,
|
||||
labelPosition: opts.labelPosition ?? null,
|
||||
};
|
||||
|
||||
const textElement: NonDeleted<ExcalidrawTextElement> = newElementWith(
|
||||
@@ -350,9 +446,18 @@ const getAdjustedDimensions = (
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
// grow away from the edge(s) the alignment anchors the text to, so that
|
||||
// the anchor stays put as the text is edited. `verticalAlign` has no
|
||||
// visual effect on unbound text, but standalone text bound to an arrow
|
||||
// endpoint uses it to pin the side the arrow attaches to.
|
||||
[x, y] = adjustXYWithRotation(
|
||||
{
|
||||
s: true,
|
||||
n:
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
|
||||
verticalAlign === VERTICAL_ALIGN.BOTTOM,
|
||||
s:
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
|
||||
verticalAlign === VERTICAL_ALIGN.TOP,
|
||||
e: textAlign === "center" || textAlign === "left",
|
||||
w: textAlign === "center" || textAlign === "right",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
clamp,
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
invariant,
|
||||
applyDarkModeFilter,
|
||||
isSafari,
|
||||
STICKY_NOTE_EDGE_SHADOW_OPACITY,
|
||||
STICKY_NOTE_EDGE_SHADOW_WIDTH,
|
||||
STICKY_NOTE_FOOTER,
|
||||
STICKY_NOTE_SHADOW_OPACITY,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -31,9 +36,8 @@ import type {
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
PendingExcalidrawElements,
|
||||
NormalizedZoomValue,
|
||||
ElementRenderOverrides,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type {
|
||||
@@ -61,12 +65,18 @@ import {
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import {
|
||||
getStickyNoteFooter,
|
||||
getStickyNotePathCommands,
|
||||
type StickyNotePathCommand,
|
||||
} from "./stickyNote";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -76,7 +86,6 @@ import type {
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
@@ -106,30 +115,90 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getRenderOpacity = (
|
||||
element: ExcalidrawElement,
|
||||
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||
elementsPendingErasure: ElementsPendingErasure,
|
||||
pendingNodes: Readonly<PendingExcalidrawElements> | null,
|
||||
globalAlpha: number = 1,
|
||||
) => {
|
||||
// multiplying frame opacity with element opacity to combine them
|
||||
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||
let opacity =
|
||||
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
|
||||
globalAlpha;
|
||||
export type RenderPositionOffset = Readonly<{ x: number; y: number }>;
|
||||
|
||||
// if pending erasure, multiply again to combine further
|
||||
// (so that erasing always results in lower opacity than original)
|
||||
const ZERO_RENDER_OFFSET: RenderPositionOffset = { x: 0, y: 0 };
|
||||
|
||||
/** Bound labels follow their container; a label's own offset is ignored. */
|
||||
export const getElementRenderOffset = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
overrides: ElementRenderOverrides | undefined,
|
||||
): RenderPositionOffset | undefined => {
|
||||
const container = isTextElement(element)
|
||||
? getContainerElement(element, elementsMap)
|
||||
: null;
|
||||
return overrides?.get((container ?? element).id)?.offset;
|
||||
};
|
||||
|
||||
export const getRenderElementWithPositionOverride = <
|
||||
TElement extends ExcalidrawElement,
|
||||
>(
|
||||
element: TElement,
|
||||
positionOffset: RenderPositionOffset,
|
||||
): TElement => {
|
||||
if (positionOffset.x === 0 && positionOffset.y === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
x: element.x + positionOffset.x,
|
||||
y: element.y + positionOffset.y,
|
||||
} as TElement;
|
||||
};
|
||||
|
||||
export type ElementRenderState = Readonly<{
|
||||
/** Alpha including frame opacity and pending erasure; selection dimming is separate. */
|
||||
opacity: number;
|
||||
offset: RenderPositionOffset;
|
||||
}>;
|
||||
|
||||
/** Resolve visual state at the drawing boundary, preserving document cache keys. */
|
||||
export const resolveElementRenderState = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
renderConfig: Pick<
|
||||
StaticCanvasRenderConfig,
|
||||
| "elementRenderOverrides"
|
||||
| "elementsPendingErasure"
|
||||
| "pendingFlowchartNodes"
|
||||
>,
|
||||
allElementsMap: ElementsMap = elementsMap,
|
||||
): ElementRenderState => {
|
||||
const {
|
||||
elementRenderOverrides,
|
||||
elementsPendingErasure,
|
||||
pendingFlowchartNodes,
|
||||
} = renderConfig;
|
||||
const override = elementRenderOverrides?.get(element.id);
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
const frameOpacity = containingFrame
|
||||
? clamp(
|
||||
elementRenderOverrides?.get(containingFrame.id)?.opacity ??
|
||||
containingFrame.opacity,
|
||||
0,
|
||||
100,
|
||||
)
|
||||
: 100;
|
||||
// Frame and element alpha multiply (50% each produces 25%).
|
||||
let opacity =
|
||||
(frameOpacity * clamp(override?.opacity ?? element.opacity, 0, 100)) /
|
||||
10000;
|
||||
if (
|
||||
elementsPendingErasure.has(element.id) ||
|
||||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
|
||||
pendingFlowchartNodes?.some((node) => node.id === element.id) ||
|
||||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||
) {
|
||||
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||
}
|
||||
const offset = getElementRenderOffset(
|
||||
element,
|
||||
allElementsMap,
|
||||
elementRenderOverrides,
|
||||
);
|
||||
|
||||
return opacity;
|
||||
return { opacity, offset: offset ?? ZERO_RENDER_OFFSET };
|
||||
};
|
||||
|
||||
export interface ExcalidrawElementWithCanvas {
|
||||
@@ -315,6 +384,50 @@ const drawImagePlaceholder = (
|
||||
);
|
||||
};
|
||||
|
||||
const drawStickyNotePath = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
context.beginPath();
|
||||
for (const command of commands) {
|
||||
if (command.type === "move") {
|
||||
context.moveTo(command.point.x, command.point.y);
|
||||
} else if (command.type === "line") {
|
||||
context.lineTo(command.point.x, command.point.y);
|
||||
} else {
|
||||
context.quadraticCurveTo(
|
||||
command.control.x,
|
||||
command.control.y,
|
||||
command.point.x,
|
||||
command.point.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
context.closePath();
|
||||
};
|
||||
|
||||
const fillStickyNoteShape = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
drawStickyNotePath(context, commands);
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const strokeStickyNoteEdge = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
context.save();
|
||||
drawStickyNotePath(context, commands);
|
||||
context.clip();
|
||||
context.lineWidth = STICKY_NOTE_EDGE_SHADOW_WIDTH * 2;
|
||||
context.strokeStyle = `rgba(0, 0, 0, ${STICKY_NOTE_EDGE_SHADOW_OPACITY})`;
|
||||
drawStickyNotePath(context, commands);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawElementOnCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
@@ -322,6 +435,41 @@ const drawElementOnCanvas = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "stickynote": {
|
||||
context.save();
|
||||
context.fillStyle = `rgba(0, 0, 0, ${STICKY_NOTE_SHADOW_OPACITY})`;
|
||||
fillStickyNoteShape(
|
||||
context,
|
||||
getStickyNotePathCommands(element, { shadow: true }),
|
||||
);
|
||||
|
||||
const commands = getStickyNotePathCommands(element);
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.backgroundColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
fillStickyNoteShape(context, commands);
|
||||
strokeStickyNoteEdge(context, commands);
|
||||
|
||||
// the label is absolute, so this cached canvas only goes stale at a
|
||||
// year boundary — and is regenerated on the next zoom, theme or
|
||||
// element change anyway
|
||||
const footer = getStickyNoteFooter(element);
|
||||
if (footer) {
|
||||
context.font = `${STICKY_NOTE_FOOTER.fontSize}px ${STICKY_NOTE_FOOTER.fontFamily}`;
|
||||
context.textAlign = "right";
|
||||
context.textBaseline = "alphabetic";
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.strokeColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
context.globalAlpha *= STICKY_NOTE_FOOTER.opacity;
|
||||
context.fillText(footer.text, footer.x, footer.y);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
@@ -583,21 +731,59 @@ const generateElementWithCanvas = (
|
||||
return prevElementWithCanvas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an element's cached bitmap may be laid on the device-pixel grid:
|
||||
* unrotated or at a right angle, and not during a zoom gesture. The cached
|
||||
* path disables smoothing for exactly these — a nearest-neighbor 1:1 blit is
|
||||
* a pixel-exact copy, and rounding its origin keeps it off the half-pixel
|
||||
* boundary where it tears — and every eligible blit is snapped. It is
|
||||
* eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with
|
||||
* smoothing on, and a size-capped cache is rescaled; snapping is harmless
|
||||
* for both.
|
||||
*
|
||||
* Not during a zoom gesture: the bitmaps stay at the old scale and are
|
||||
* resampled, and blurry shapes look better on low resolution (while still
|
||||
* zooming in) than sharp ones; snapping would only make elements twitch.
|
||||
* Right angles qualify because smoothing can be off there without aliasing
|
||||
* (for other angles it is terrible on Chromium); the right-angle test
|
||||
* tolerates float arithmetic.
|
||||
*/
|
||||
const canSnapElement = (
|
||||
element: ExcalidrawElement,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) =>
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
(!element.angle || isRightAngleRads(element.angle));
|
||||
|
||||
/**
|
||||
* Breaks a `Math.round` tie at exactly half a device pixel the same way
|
||||
* every frame. A label's offset from its container lands on one routinely
|
||||
* (an odd scene offset at 150%), and the float noise of a fractional drag
|
||||
* would otherwise flip it between the two neighbors.
|
||||
*/
|
||||
const SNAP_TIE_BIAS = 1e-6;
|
||||
|
||||
const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
positionOffset: RenderPositionOffset,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
// the ratio the cached bitmap was generated with (`generateElementCanvas`);
|
||||
// the blit's size math needs the same one, so it is not the owner window's
|
||||
const devicePixelRatio = window.devicePixelRatio;
|
||||
const padding = getCanvasPadding(element);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||
const cx =
|
||||
((x1 + x2) / 2 + positionOffset.x + appState.scrollX) * devicePixelRatio;
|
||||
const cy =
|
||||
((y1 + y2) / 2 + positionOffset.y + appState.scrollY) * devicePixelRatio;
|
||||
|
||||
context.save();
|
||||
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
|
||||
context.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, allElementsMap);
|
||||
|
||||
@@ -612,7 +798,7 @@ const drawElementFromCanvas = (
|
||||
);
|
||||
// generously covers the arrow's blit at any rotation
|
||||
const outerHalf =
|
||||
Math.max(distance(x1, x2), distance(y1, y2)) * window.devicePixelRatio +
|
||||
Math.max(distance(x1, x2), distance(y1, y2)) * devicePixelRatio +
|
||||
padding * 10;
|
||||
context.beginPath();
|
||||
context.rect(cx - outerHalf, cy - outerHalf, outerHalf * 2, outerHalf * 2);
|
||||
@@ -620,17 +806,17 @@ const drawElementFromCanvas = (
|
||||
(boundTextCx -
|
||||
boundTextElement.width / 2 -
|
||||
BOUND_TEXT_PADDING +
|
||||
positionOffset.x +
|
||||
appState.scrollX) *
|
||||
window.devicePixelRatio,
|
||||
devicePixelRatio,
|
||||
(boundTextCy -
|
||||
boundTextElement.height / 2 -
|
||||
BOUND_TEXT_PADDING +
|
||||
positionOffset.y +
|
||||
appState.scrollY) *
|
||||
window.devicePixelRatio,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio,
|
||||
devicePixelRatio,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
|
||||
);
|
||||
context.clip("evenodd");
|
||||
}
|
||||
@@ -654,16 +840,79 @@ const drawElementFromCanvas = (
|
||||
// revert afterwards we don't have account for it during drawing
|
||||
context.translate(-cx, -cy);
|
||||
|
||||
// the blit origin, in the space the context is in here (scaled by
|
||||
// canvas scale × zoom ÷ devicePixelRatio)
|
||||
let drawX =
|
||||
(x1 + positionOffset.x + appState.scrollX) * devicePixelRatio - padding;
|
||||
let drawY =
|
||||
(y1 + positionOffset.y + appState.scrollY) * devicePixelRatio - padding;
|
||||
|
||||
const transform = context.getTransform();
|
||||
|
||||
if (canSnapElement(element, appState)) {
|
||||
// blit the cached bitmap on whole device pixels. Nearest-neighbor at a
|
||||
// fractional offset is a pixel-exact copy shifted to the nearest pixel
|
||||
// — except at an exact half pixel, where a GPU-accelerated canvas
|
||||
// decides the rounding per scanline by float precision and a few rows
|
||||
// sample the neighboring row: doubled or broken strokes, varying with
|
||||
// scroll and position. A centered label lands on a half pixel
|
||||
// routinely.
|
||||
//
|
||||
// Done by moving the transform's origin onto the rounded device
|
||||
// position of the blit and drawing at (0, 0): the rotation, mirroring
|
||||
// and scale stay in `a`–`d` (a right angle keeps its scale in `b`/`c`),
|
||||
// and the origin is an exact integer even in the canvas's float32
|
||||
// matrix. Rounding `drawX` itself only lands on device pixels at 100%
|
||||
// zoom.
|
||||
const { a, b, c, d, e, f } = transform;
|
||||
const container = isTextElement(element)
|
||||
? getContainerElement(element, allElementsMap)
|
||||
: null;
|
||||
// A bound label shares its unrotated container's offset and anchor.
|
||||
// Other bitmaps anchor to themselves.
|
||||
const anchor = container && !container.angle ? container : element;
|
||||
const anchorCoords =
|
||||
anchor === element
|
||||
? null
|
||||
: getElementAbsoluteCoords(anchor, allElementsMap);
|
||||
const anchorSceneX = anchorCoords?.[0] ?? x1;
|
||||
const anchorSceneY = anchorCoords?.[1] ?? y1;
|
||||
const anchorPadding =
|
||||
anchor === element ? padding : getCanvasPadding(anchor);
|
||||
const anchorX =
|
||||
(anchorSceneX + positionOffset.x + appState.scrollX) * devicePixelRatio -
|
||||
anchorPadding;
|
||||
const anchorY =
|
||||
(anchorSceneY + positionOffset.y + appState.scrollY) * devicePixelRatio -
|
||||
anchorPadding;
|
||||
|
||||
// Form the relative vector before introducing the scroll translation.
|
||||
const dx = (x1 - anchorSceneX) * devicePixelRatio + anchorPadding - padding;
|
||||
const dy = (y1 - anchorSceneY) * devicePixelRatio + anchorPadding - padding;
|
||||
context.setTransform(
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
Math.round(a * anchorX + c * anchorY + e) +
|
||||
Math.round(a * dx + c * dy + SNAP_TIE_BIAS),
|
||||
Math.round(b * anchorX + d * anchorY + f) +
|
||||
Math.round(b * dx + d * dy + SNAP_TIE_BIAS),
|
||||
);
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(x1 + appState.scrollX) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
(y1 + appState.scrollY) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
drawX,
|
||||
drawY,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||
);
|
||||
|
||||
context.setTransform(transform);
|
||||
|
||||
if (
|
||||
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
|
||||
"true" &&
|
||||
@@ -677,10 +926,10 @@ const drawElementFromCanvas = (
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + appState.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + appState.scrollY) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
(coords.x + positionOffset.x + appState.scrollX) * devicePixelRatio,
|
||||
(coords.y + positionOffset.y + appState.scrollY) * devicePixelRatio,
|
||||
getBoundTextMaxWidth(element, textElement) * devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * devicePixelRatio,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
@@ -696,7 +945,10 @@ export const renderSelectionElement = (
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
"rgba(0, 0, 200, 0.04)",
|
||||
appState.theme === THEME.DARK,
|
||||
);
|
||||
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
||||
@@ -720,20 +972,56 @@ export const renderElement = (
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
renderState = resolveElementRenderState(
|
||||
element,
|
||||
elementsMap,
|
||||
renderConfig,
|
||||
allElementsMap,
|
||||
),
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
!appState.selectedElementIds[element.id] &&
|
||||
!appState.hoveredElementIds[element.id];
|
||||
|
||||
context.globalAlpha = getRenderOpacity(
|
||||
element,
|
||||
getContainingFrame(element, elementsMap),
|
||||
renderConfig.elementsPendingErasure,
|
||||
renderConfig.pendingFlowchartNodes,
|
||||
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
|
||||
);
|
||||
context.save();
|
||||
context.globalAlpha =
|
||||
renderState.opacity *
|
||||
(reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1);
|
||||
// Cached bitmaps apply the offset before pixel snapping. Moving it into
|
||||
// the canvas transform first loses precision at half-device-pixel ties.
|
||||
if (
|
||||
(renderConfig.isExporting || isFrameLikeElement(element)) &&
|
||||
(renderState.offset.x || renderState.offset.y)
|
||||
) {
|
||||
context.translate(renderState.offset.x, renderState.offset.y);
|
||||
}
|
||||
try {
|
||||
drawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
allElementsMap,
|
||||
rc,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
renderState,
|
||||
);
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const drawElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: RenderableElementsMap,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
renderState: ElementRenderState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "magicframe":
|
||||
case "frame": {
|
||||
@@ -808,12 +1096,14 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
renderState.offset,
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line":
|
||||
@@ -918,17 +1208,9 @@ export const renderElement = (
|
||||
|
||||
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
|
||||
|
||||
if (
|
||||
// do not disable smoothing during zoom as blurry shapes look better
|
||||
// on low resolution (while still zooming in) than sharp ones
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
// angle is 0 -> always disable smoothing
|
||||
(!element.angle ||
|
||||
// or check if angle is a right angle in which case we can still
|
||||
// disable smoothing without adversely affecting the result
|
||||
// We need less-than comparison because of FP artihmetic
|
||||
isRightAngleRads(element.angle))
|
||||
) {
|
||||
// see `canSnapElement` for why not during zoom gestures or at
|
||||
// other angles
|
||||
if (canSnapElement(element, appState)) {
|
||||
// Disabling smoothing makes output much sharper, especially for
|
||||
// text. Unless for non-right angles, where the aliasing is really
|
||||
// terrible on Chromium.
|
||||
@@ -965,6 +1247,8 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
// The crop editor's uncropped preview stays at document coordinates.
|
||||
ZERO_RENDER_OFFSET,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -977,6 +1261,7 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
renderState.offset,
|
||||
);
|
||||
|
||||
// reset
|
||||
@@ -989,8 +1274,6 @@ export const renderElement = (
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
export function getFreedrawOutlineAsSegments(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
MIN_FONT_SIZE,
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
rescalePoints,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
@@ -57,10 +58,16 @@ import {
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
import {
|
||||
getStickyNoteMinSize,
|
||||
getStickyNoteResizeIntent,
|
||||
updateStickyNoteLayout,
|
||||
} from "./stickyNote";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -749,11 +756,54 @@ export const resizeSingleElement = (
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
// Constraints and font scaling use magnitudes. Keep the signs for the
|
||||
// geometry below, where crossing the opposite edge flips the element.
|
||||
const flipFactorX = nextWidth < 0 ? -1 : 1;
|
||||
const flipFactorY = nextHeight < 0 ? -1 : 1;
|
||||
nextWidth = Math.abs(nextWidth);
|
||||
nextHeight = Math.abs(nextHeight);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
const isResizingStickyNote = isStickyNoteElement(latestElement);
|
||||
let minSize: { width: number; height: number } | undefined;
|
||||
if (isResizingStickyNote) {
|
||||
// A note must fit one line at its label's font ceiling.
|
||||
minSize = boundTextElement
|
||||
? getStickyNoteMinSize({
|
||||
fontSize: boundTextElement.baseFontSize ?? boundTextElement.fontSize,
|
||||
fontFamily: boundTextElement.fontFamily,
|
||||
})
|
||||
: { width: STICKY_NOTE_MIN_SIZE, height: STICKY_NOTE_MIN_SIZE };
|
||||
} else if (boundTextElement && !shouldMaintainAspectRatio) {
|
||||
minSize = {
|
||||
width: getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
),
|
||||
height: getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
if (minSize) {
|
||||
nextWidth = Math.max(nextWidth, minSize.width);
|
||||
nextHeight = Math.max(nextHeight, minSize.height);
|
||||
if (shouldMaintainAspectRatio) {
|
||||
// Both dimensions must use the same scale even at the minimum size.
|
||||
const scale = Math.max(
|
||||
nextWidth / origElement.width,
|
||||
nextHeight / origElement.height,
|
||||
);
|
||||
nextWidth = origElement.width * scale;
|
||||
nextHeight = origElement.height * scale;
|
||||
}
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
if (boundTextElement && !isResizingStickyNote) {
|
||||
const stateOfBoundTextElementAtResize = originalElementsMap.get(
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
@@ -780,20 +830,12 @@ export const resizeSingleElement = (
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
nextWidth = Math.max(nextWidth, minWidth);
|
||||
nextHeight = Math.max(nextHeight, minHeight);
|
||||
}
|
||||
}
|
||||
|
||||
nextWidth *= flipFactorX;
|
||||
nextHeight *= flipFactorY;
|
||||
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
origElement,
|
||||
nextWidth,
|
||||
@@ -865,7 +907,7 @@ export const resizeSingleElement = (
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(nextWidth / latestElement.width) * boundTextElement.fontSize;
|
||||
(Math.abs(nextWidth) / latestElement.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
@@ -909,18 +951,35 @@ export const resizeSingleElement = (
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
if (isStickyNoteElement(latestElement)) {
|
||||
updateStickyNoteLayout(latestElement, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
latestElement,
|
||||
originalElementsMap,
|
||||
handleDirection,
|
||||
{
|
||||
proportional: shouldMaintainAspectRatio,
|
||||
fromCenter: shouldResizeFromCenter,
|
||||
},
|
||||
),
|
||||
// the arrow pass below is this function's — keep it single
|
||||
bindings: false,
|
||||
});
|
||||
} else {
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
flipFactorY < 0,
|
||||
);
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene);
|
||||
}
|
||||
@@ -1441,7 +1500,8 @@ export const resizeMultipleElements = (
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
// sticky notes derive their label's size from the layout below
|
||||
if (boundTextElement && !isStickyNoteElement(orig)) {
|
||||
if (keepAspectRatio) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
@@ -1473,9 +1533,27 @@ export const resizeMultipleElements = (
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// the content correction runs before the (single) arrow pass, which
|
||||
// must still skip arrows resized in the same gesture
|
||||
updateStickyNoteLayout(element, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
element,
|
||||
originalElementsMap,
|
||||
handleDirection,
|
||||
{
|
||||
proportional: keepAspectRatio,
|
||||
fromCenter: shouldResizeFromCenter,
|
||||
flip: flipByX || flipByY,
|
||||
},
|
||||
),
|
||||
bindings: { simultaneouslyUpdated: elementsToUpdate },
|
||||
});
|
||||
} else {
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
@@ -1491,7 +1569,11 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
boundTextFontSize &&
|
||||
!isStickyNoteElement(element)
|
||||
) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
@@ -1502,6 +1584,7 @@ export const resizeMultipleElements = (
|
||||
handleDirection,
|
||||
true,
|
||||
shouldResizeFromCenter,
|
||||
flipByY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,12 +216,15 @@ export const getTargetElements = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<
|
||||
AppState,
|
||||
"selectedElementIds" | "editingTextElement" | "newElement"
|
||||
"selectedElementIds" | "editingTextElement" | "newElement" | "activeTool"
|
||||
>,
|
||||
) =>
|
||||
appState.editingTextElement
|
||||
? [appState.editingTextElement]
|
||||
: appState.newElement
|
||||
: // the drawShape recognition preview in `newElement` is not a real
|
||||
// in-progress element — targeting it would make the styles panel track
|
||||
// whatever shape is currently recognized instead of the tool defaults
|
||||
appState.newElement && appState.activeTool.type !== "autoshape"
|
||||
? [appState.newElement]
|
||||
: getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
import { getStroke, getStrokePoints } from "perfect-freehand";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import {
|
||||
@@ -979,6 +979,9 @@ const _generateElementShape = (
|
||||
|
||||
return shapes;
|
||||
}
|
||||
// sticky notes are painted directly (canvas + SVG) from
|
||||
// `getStickyNoteRenderPoints`, never through roughjs
|
||||
case "stickynote":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "text":
|
||||
@@ -1073,6 +1076,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
@@ -1253,6 +1257,26 @@ export const getFreedrawOutlinePoints = (
|
||||
: getVariableWidthFreedrawOutline(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* The streamline-smoothed centerline the freedraw stroke is rendered
|
||||
* around, in element-local coordinates. Boundary-sensitive consumers (e.g.
|
||||
* bucket fill) should use this instead of `element.points`: raw input
|
||||
* points can sit 20px+ apart and the rendered stroke is smoothed between
|
||||
* them, so raw chords visibly deviate from what's on screen.
|
||||
*
|
||||
* Constant-width ("laser geometry") strokes technically smooth via
|
||||
* `LaserPointer` instead; the perfect-freehand centerline with the same
|
||||
* `streamline` is a close approximation the stroke width hides.
|
||||
*/
|
||||
export const getFreedrawStrokeCenterPoints = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number][] =>
|
||||
getStrokePoints(element.points as unknown as number[][], {
|
||||
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
|
||||
streamline: getFreedrawStreamline(element),
|
||||
last: true,
|
||||
}).map((strokePoint) => strokePoint.point as [number, number]);
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
@@ -22,6 +22,37 @@ import {
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export type VerticalResizeAnchor = "top" | "bottom" | "center";
|
||||
|
||||
/** Keeps the chosen edge or center fixed when an element's height changes. */
|
||||
export const getPositionAfterHeightChange = (
|
||||
element: Pick<ExcalidrawElement, "x" | "y" | "height" | "angle">,
|
||||
nextHeight: number,
|
||||
anchor: VerticalResizeAnchor = "top",
|
||||
) => {
|
||||
const delta = (element.height - nextHeight) / 2;
|
||||
|
||||
if (anchor === "center") {
|
||||
// Rotation is about the center, so holding it is angle-independent.
|
||||
return { x: element.x, y: element.y + delta };
|
||||
}
|
||||
|
||||
const sin = Math.sin(element.angle);
|
||||
const cos = Math.cos(element.angle);
|
||||
|
||||
if (anchor === "bottom") {
|
||||
return {
|
||||
x: element.x - delta * sin,
|
||||
y: element.y + delta * (1 + cos),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: element.x + delta * sin,
|
||||
y: element.y + delta * (1 - cos),
|
||||
};
|
||||
};
|
||||
|
||||
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
|
||||
@@ -62,12 +62,12 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
|
||||
* original z-index of container (i.e. it moves bound text elements after
|
||||
* containers).
|
||||
*/
|
||||
const normalizeBoundElementsOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
export const normalizeBoundElementsOrder = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
) => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
const sortedElements = new Set<T>();
|
||||
|
||||
for (const element of elements) {
|
||||
if (sortedElements.has(element)) {
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
MIN_FONT_SIZE,
|
||||
arrayToMap,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_FALLBACK_FONT_SIZE,
|
||||
STICKY_NOTE_FOOTER,
|
||||
STICKY_NOTE_FONT_STEP,
|
||||
STICKY_NOTE_MAX_FONT_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
STICKY_NOTE_MIN_FONT_SIZE,
|
||||
STICKY_NOTE_PADDING,
|
||||
STICKY_NOTE_SHADOW_OFFSET,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
seededRandom,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { getPositionAfterHeightChange } from "./sizeHelpers";
|
||||
import { computeBoundTextPosition, getBoundTextElement } from "./textElement";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isStickyNoteElement, isTextElement } from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type { VerticalResizeAnchor } from "./sizeHelpers";
|
||||
import type { TransformHandleDirection } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
export type StickyNoteRenderPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type StickyNotePathCommand =
|
||||
| {
|
||||
type: "move";
|
||||
point: StickyNoteRenderPoint;
|
||||
}
|
||||
| {
|
||||
type: "line";
|
||||
point: StickyNoteRenderPoint;
|
||||
}
|
||||
| {
|
||||
type: "quadratic";
|
||||
control: StickyNoteRenderPoint;
|
||||
point: StickyNoteRenderPoint;
|
||||
};
|
||||
|
||||
const STICKY_NOTE_RENDER_ROUGHNESS = [0, 1.5, 8] as const;
|
||||
const STICKY_NOTE_CORNER_RADIUS_RATIO = 0.04;
|
||||
const STICKY_NOTE_MAX_CORNER_RADIUS = 16;
|
||||
|
||||
export const normalizeStickyNoteStrokeColor = (
|
||||
strokeColor: string | null | undefined,
|
||||
) => {
|
||||
return !strokeColor || isTransparent(strokeColor)
|
||||
? DEFAULT_ELEMENT_PROPS.strokeColor
|
||||
: strokeColor;
|
||||
};
|
||||
|
||||
export const normalizeStickyNoteBackgroundColor = (
|
||||
backgroundColor: string | null | undefined,
|
||||
) => {
|
||||
return !backgroundColor || isTransparent(backgroundColor)
|
||||
? DEFAULT_STICKY_NOTE_BG
|
||||
: backgroundColor;
|
||||
};
|
||||
|
||||
/**
|
||||
* The update that applies a picked color to an element under the sticky
|
||||
* note policy: a note is always filled and its label — the visible text,
|
||||
* which the note's own `strokeColor` seeds — never goes transparent. Every
|
||||
* color surface (actions, both eyedroppers, paste styles, bind) routes
|
||||
* through this instead of re-deriving the rule.
|
||||
*/
|
||||
/**
|
||||
* The element a color pick on `element` lands on. A note's label has no fill
|
||||
* of its own, so a background pick on the label — the styles panel while
|
||||
* editing it — goes to the note; everything else colors itself.
|
||||
*/
|
||||
export const getColorTargetElement = (
|
||||
element: ExcalidrawElement,
|
||||
property: "strokeColor" | "backgroundColor",
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawElement => {
|
||||
if (
|
||||
property === "backgroundColor" &&
|
||||
isTextElement(element) &&
|
||||
isStickyNoteBoundText(element, elementsMap)
|
||||
) {
|
||||
return elementsMap.get(element.containerId!) ?? element;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
export const getColorUpdate = (
|
||||
element: ExcalidrawElement,
|
||||
property: "strokeColor" | "backgroundColor",
|
||||
color: string,
|
||||
elementsMap: ElementsMap,
|
||||
): { strokeColor: string } | { backgroundColor: string } => {
|
||||
if (isStickyNoteElement(element)) {
|
||||
return property === "backgroundColor"
|
||||
? { backgroundColor: normalizeStickyNoteBackgroundColor(color) }
|
||||
: { strokeColor: normalizeStickyNoteStrokeColor(color) };
|
||||
}
|
||||
if (
|
||||
property === "strokeColor" &&
|
||||
isTextElement(element) &&
|
||||
isStickyNoteBoundText(element, elementsMap)
|
||||
) {
|
||||
const container = elementsMap.get(element.containerId!);
|
||||
return {
|
||||
strokeColor: isTransparent(color)
|
||||
? normalizeStickyNoteStrokeColor(container?.strokeColor)
|
||||
: color,
|
||||
};
|
||||
}
|
||||
return property === "backgroundColor"
|
||||
? { backgroundColor: color }
|
||||
: { strokeColor: color };
|
||||
};
|
||||
|
||||
/**
|
||||
* A note's ink is one color: the container's `strokeColor` — the seed for a
|
||||
* new label and what the creation-date footer paints with — and its label's.
|
||||
* Run after a property write to copy the side that changed onto the other;
|
||||
* when both changed, or neither (data that drifted), the label wins: it is
|
||||
* the text the user styled. A transparent label always takes the note's
|
||||
* color. Returns the same array when nothing needs to change.
|
||||
*/
|
||||
export const syncStickyNoteInk = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
prevElementsMap: ElementsMap,
|
||||
): readonly T[] => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const inkById = new Map<string, string>();
|
||||
for (const container of elements) {
|
||||
if (!isStickyNoteElement(container) || container.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
const label = getBoundTextElement(container, elementsMap);
|
||||
if (!label || label.strokeColor === container.strokeColor) {
|
||||
continue;
|
||||
}
|
||||
const containerChanged =
|
||||
prevElementsMap.get(container.id)?.strokeColor !== container.strokeColor;
|
||||
const labelChanged =
|
||||
prevElementsMap.get(label.id)?.strokeColor !== label.strokeColor;
|
||||
if (
|
||||
isTransparent(label.strokeColor) ||
|
||||
(containerChanged && !labelChanged)
|
||||
) {
|
||||
inkById.set(
|
||||
label.id,
|
||||
normalizeStickyNoteStrokeColor(container.strokeColor),
|
||||
);
|
||||
} else {
|
||||
inkById.set(
|
||||
container.id,
|
||||
normalizeStickyNoteStrokeColor(label.strokeColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!inkById.size) {
|
||||
return elements;
|
||||
}
|
||||
return elements.map((element) => {
|
||||
const strokeColor = inkById.get(element.id);
|
||||
return strokeColor === undefined
|
||||
? element
|
||||
: (newElementWith(element as ExcalidrawElement, { strokeColor }) as T);
|
||||
});
|
||||
};
|
||||
|
||||
const jitter = (random: () => number, amount: number) =>
|
||||
(random() * 2 - 1) * amount;
|
||||
|
||||
const pointAtDistance = (
|
||||
from: StickyNoteRenderPoint,
|
||||
to: StickyNoteRenderPoint,
|
||||
distance: number,
|
||||
) => {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
|
||||
if (!length) {
|
||||
return from;
|
||||
}
|
||||
|
||||
const ratio = Math.min(distance / length, 1);
|
||||
return {
|
||||
x: from.x + dx * ratio,
|
||||
y: from.y + dy * ratio,
|
||||
};
|
||||
};
|
||||
|
||||
export const getStickyNoteCornerRadius = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
) => {
|
||||
if (!element.roundness) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
Math.min(element.width, element.height) * STICKY_NOTE_CORNER_RADIUS_RATIO,
|
||||
STICKY_NOTE_MAX_CORNER_RADIUS,
|
||||
);
|
||||
};
|
||||
|
||||
export const getStickyNoteRenderPoints = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
seedOffset = 0,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
seedOffset?: number;
|
||||
} = {},
|
||||
): StickyNoteRenderPoint[] => {
|
||||
const roughness = Math.max(0, Math.min(2, Math.round(element.roughness)));
|
||||
const amount = Math.min(
|
||||
STICKY_NOTE_RENDER_ROUGHNESS[roughness],
|
||||
Math.min(element.width, element.height) * 0.012,
|
||||
);
|
||||
|
||||
if (!amount) {
|
||||
return [
|
||||
{ x: offsetX, y: offsetY },
|
||||
{ x: offsetX + element.width, y: offsetY },
|
||||
{ x: offsetX + element.width, y: offsetY + element.height },
|
||||
{ x: offsetX, y: offsetY + element.height },
|
||||
];
|
||||
}
|
||||
|
||||
const random = seededRandom(element.seed + seedOffset);
|
||||
|
||||
return [
|
||||
{
|
||||
x: offsetX + jitter(random, amount),
|
||||
y: offsetY + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + element.width + jitter(random, amount),
|
||||
y: offsetY + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + element.width + jitter(random, amount),
|
||||
y: offsetY + element.height + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + jitter(random, amount),
|
||||
y: offsetY + element.height + jitter(random, amount),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getStickyNotePathCommands = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
{ shadow = false }: { shadow?: boolean } = {},
|
||||
): StickyNotePathCommand[] => {
|
||||
const points = getStickyNoteRenderPoints(
|
||||
element,
|
||||
shadow
|
||||
? {
|
||||
offsetX: STICKY_NOTE_SHADOW_OFFSET,
|
||||
offsetY: STICKY_NOTE_SHADOW_OFFSET,
|
||||
seedOffset: 1,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const radius = getStickyNoteCornerRadius(element);
|
||||
// The note's seed keeps the same corner for the paper and shadow through
|
||||
// redraws and resizes.
|
||||
const liftedCorner =
|
||||
element.roughness === 2
|
||||
? Math.floor(seededRandom(element.seed)() * points.length)
|
||||
: -1;
|
||||
|
||||
if (!radius && liftedCorner === -1) {
|
||||
return [
|
||||
{ type: "move", point: points[0] },
|
||||
...points.slice(1).map(
|
||||
(point): StickyNotePathCommand => ({
|
||||
type: "line",
|
||||
point,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const corners = points.map((point, index): StickyNotePathCommand[] => {
|
||||
const prev = points[(index + points.length - 1) % points.length];
|
||||
const next = points[(index + 1) % points.length];
|
||||
const cornerRadius = Math.min(
|
||||
radius,
|
||||
Math.hypot(point.x - prev.x, point.y - prev.y) / 2,
|
||||
Math.hypot(point.x - next.x, point.y - next.y) / 2,
|
||||
);
|
||||
|
||||
if (index === liftedCorner) {
|
||||
const size = Math.min(element.width, element.height);
|
||||
const reach = Math.min(size * 0.18, 40);
|
||||
// The shadow follows the paper inward at half the bend, keeping its
|
||||
// usual down-right offset from the light at the top left.
|
||||
const lift = Math.min(size * 0.02, 5) * (shadow ? 0.5 : 1);
|
||||
// The bend joins the straight edges before the corner, keeping it local.
|
||||
const tip = {
|
||||
x: point.x + (index === 1 || index === 2 ? -lift : lift),
|
||||
y: point.y + (index >= 2 ? -lift : lift),
|
||||
};
|
||||
const start = pointAtDistance(point, prev, reach);
|
||||
const end = pointAtDistance(point, next, reach);
|
||||
return [
|
||||
{ type: "line", point: start },
|
||||
{
|
||||
type: "quadratic",
|
||||
control: pointAtDistance(point, prev, reach / 2),
|
||||
point: pointAtDistance(tip, start, cornerRadius),
|
||||
},
|
||||
{
|
||||
type: "quadratic",
|
||||
control: tip,
|
||||
point: pointAtDistance(tip, end, cornerRadius),
|
||||
},
|
||||
{
|
||||
type: "quadratic",
|
||||
control: pointAtDistance(point, next, reach / 2),
|
||||
point: end,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "line", point: pointAtDistance(point, prev, cornerRadius) },
|
||||
{
|
||||
type: "quadratic",
|
||||
control: point,
|
||||
point: pointAtDistance(point, next, cornerRadius),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const commands: StickyNotePathCommand[] = [
|
||||
{ type: "move", point: corners[0][corners[0].length - 1].point },
|
||||
];
|
||||
|
||||
for (let index = 1; index <= corners.length; index++) {
|
||||
commands.push(...corners[index % corners.length]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// font size — `baseFontSize` is the size the user picked, `fontSize` the fitted one
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// clamping the ceiling: the fit steps down from it in `STICKY_NOTE_FONT_STEP`
|
||||
// increments, and for values >= ~2^56 (1e20 - 2 === 1e20) that would never
|
||||
// progress and hang the editor
|
||||
export const normalizeStickyNoteFontSize = (fontSize: number) => {
|
||||
if (!Number.isFinite(fontSize)) {
|
||||
return STICKY_NOTE_FALLBACK_FONT_SIZE;
|
||||
}
|
||||
return Math.min(STICKY_NOTE_MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize));
|
||||
};
|
||||
|
||||
/** whether the text element is the label of a sticky note */
|
||||
export const isStickyNoteBoundText = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!!textElement.containerId &&
|
||||
isStickyNoteElement(elementsMap.get(textElement.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The font size the user picked: the ceiling the auto-fit shrinks from for a
|
||||
* sticky note label, plain `fontSize` for any other text.
|
||||
*
|
||||
* Container-aware on purpose — generic binding repair (duplication, history)
|
||||
* rewrites `containerId` without touching the ceiling, so a numeric
|
||||
* `baseFontSize` alone does not prove the text is still sticky-bound.
|
||||
*/
|
||||
export const getBaseFontSize = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return isStickyNoteBoundText(textElement, elementsMap)
|
||||
? textElement.baseFontSize ?? textElement.fontSize
|
||||
: textElement.fontSize;
|
||||
};
|
||||
|
||||
/** the update that applies a user-picked font size (see `getBaseFontSize`) */
|
||||
export const getBaseFontSizeUpdate = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
fontSize: number,
|
||||
elementsMap: ElementsMap,
|
||||
): { fontSize: number } | { baseFontSize: number } => {
|
||||
return isStickyNoteBoundText(textElement, elementsMap)
|
||||
? { baseFontSize: normalizeStickyNoteFontSize(fontSize) }
|
||||
: { fontSize };
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
/**
|
||||
* The creation-date label: absolute, so painting needs no clock and exports
|
||||
* don't go stale, and short ("7 Sep") while the year is the current one.
|
||||
* `null` when the note has no usable timestamp (files restored without one).
|
||||
* Fixed English in the viewer's local time zone — the element package has no
|
||||
* locale, and the footer is chosen by width bucket rather than measured.
|
||||
*/
|
||||
export const getStickyNoteDateLabel = (
|
||||
created: ExcalidrawStickyNoteElement["created"],
|
||||
{ short = false, now = Date.now() }: { short?: boolean; now?: number } = {},
|
||||
): string | null => {
|
||||
if (created === null || !Number.isFinite(created)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(created);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const label = `${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
||||
const year = date.getFullYear();
|
||||
return short || year === new Date(now).getFullYear()
|
||||
? label
|
||||
: `${label} ${year}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the footer paints and where, in note-local coordinates — shared by the
|
||||
* canvas and SVG renderers, which take the font and opacity from
|
||||
* `STICKY_NOTE_FOOTER`. `null` for the 0×0 creation draft and anything under
|
||||
* the data floor, where the band would overlap the top padding.
|
||||
*/
|
||||
export const getStickyNoteFooter = (
|
||||
element: Pick<ExcalidrawStickyNoteElement, "created" | "width" | "height">,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
if (
|
||||
element.width < STICKY_NOTE_MIN_SIZE ||
|
||||
element.height < STICKY_NOTE_MIN_SIZE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = getStickyNoteDateLabel(element.created, {
|
||||
short:
|
||||
element.width - STICKY_NOTE_PADDING * 2 <
|
||||
STICKY_NOTE_FOOTER.minBodyWidthForYear,
|
||||
now,
|
||||
});
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
x: element.width - STICKY_NOTE_PADDING,
|
||||
y: element.height - STICKY_NOTE_FOOTER.baselineFromBottom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The smallest note the UI lets a user create or resize to: one line at the
|
||||
* label's font ceiling plus padding (and, vertically, the footer), never below
|
||||
* `STICKY_NOTE_MIN_SIZE`. Without the font term a fresh note would grow on
|
||||
* the very first keystroke. Data-level passes (restore, action post-passes)
|
||||
* only enforce the constant floor — the layout grows a note as needed.
|
||||
*/
|
||||
export const getStickyNoteMinSize = ({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
}: Pick<ExcalidrawTextElement, "fontSize" | "fontFamily">) => {
|
||||
const lineHeightPx = Math.ceil(
|
||||
normalizeStickyNoteFontSize(fontSize) * getLineHeight(fontFamily),
|
||||
);
|
||||
return {
|
||||
width: Math.max(
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
lineHeightPx + STICKY_NOTE_PADDING * 2,
|
||||
),
|
||||
height: Math.max(
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
lineHeightPx + STICKY_NOTE_BODY_INSET_Y,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// layout — one pure calculation, two ways of applying it
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type StickyNoteLayoutAnchor = VerticalResizeAnchor;
|
||||
|
||||
export type StickyNoteLayoutOpts = {
|
||||
/** unwrapped text to lay out; defaults to the label's `originalText` */
|
||||
originalText?: string;
|
||||
/**
|
||||
* absolute base-height intent — the gesture-start value to preserve, or the
|
||||
* height a resize asked for. Omitted = keep the note's live `baseHeight`
|
||||
* (typing, style changes, restore).
|
||||
*/
|
||||
baseHeight?: number;
|
||||
/**
|
||||
* absolute font-ceiling intent (a proportional resize passes
|
||||
* `gesture-start ceiling × scale`). Omitted = keep the label's live ceiling.
|
||||
* Never a multiplier on the live value — that compounds across pointer-moves.
|
||||
*/
|
||||
baseFontSize?: number;
|
||||
/** the edge that stays put when the content correction changes the height */
|
||||
anchor?: StickyNoteLayoutAnchor;
|
||||
};
|
||||
|
||||
export type StickyNoteLayout = {
|
||||
container: Pick<
|
||||
ExcalidrawStickyNoteElement,
|
||||
"x" | "y" | "width" | "height" | "baseHeight"
|
||||
>;
|
||||
text: Pick<
|
||||
ExcalidrawTextElement,
|
||||
| "text"
|
||||
| "fontSize"
|
||||
| "baseFontSize"
|
||||
| "width"
|
||||
| "height"
|
||||
| "x"
|
||||
| "y"
|
||||
| "angle"
|
||||
> | null;
|
||||
};
|
||||
|
||||
type FontFit = {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// `computeBoundTextPosition` only consults the map for arrow containers
|
||||
const NO_ELEMENTS: ElementsMap = new Map();
|
||||
|
||||
const getStickyNoteBaseWidth = (container: ExcalidrawStickyNoteElement) => {
|
||||
return Math.max(container.width, STICKY_NOTE_MIN_SIZE);
|
||||
};
|
||||
|
||||
/**
|
||||
* Largest font size on the grid `{ceiling − k·STEP} ∪ {min}` whose wrapped
|
||||
* text fits the note; `min` when nothing fits (the note then grows). The grid
|
||||
* is anchored at the ceiling so the result never depends on earlier edits.
|
||||
* Warm-started from the previously fitted size: the common keystroke costs one
|
||||
* or two wrap+measure passes, a cold search at most log2(steps) + 2.
|
||||
*/
|
||||
const fitStickyNoteFont = (
|
||||
fit: (fontSize: number) => FontFit,
|
||||
{
|
||||
baseFontSize,
|
||||
fontSizeMin,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
warmStart,
|
||||
}: {
|
||||
baseFontSize: number;
|
||||
fontSizeMin: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
warmStart: number;
|
||||
},
|
||||
): FontFit => {
|
||||
const steps = Math.max(
|
||||
0,
|
||||
Math.ceil((baseFontSize - fontSizeMin) / STICKY_NOTE_FONT_STEP),
|
||||
);
|
||||
const sizeAt = (index: number) =>
|
||||
index >= steps ? fontSizeMin : baseFontSize - index * STICKY_NOTE_FONT_STEP;
|
||||
|
||||
const fits = new Map<number, FontFit>();
|
||||
const at = (index: number) => {
|
||||
let result = fits.get(index);
|
||||
if (!result) {
|
||||
result = fit(sizeAt(index));
|
||||
fits.set(index, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const doesFit = (index: number) => {
|
||||
const result = at(index);
|
||||
return result.width <= maxWidth && result.height <= maxHeight;
|
||||
};
|
||||
|
||||
if (steps === 0) {
|
||||
return at(0);
|
||||
}
|
||||
|
||||
// the previous fitted size, snapped onto the grid and clamped into the
|
||||
// current interval (a lowered ceiling must not keep the old larger size)
|
||||
const warm = clamp(
|
||||
Math.round((baseFontSize - warmStart) / STICKY_NOTE_FONT_STEP),
|
||||
0,
|
||||
steps,
|
||||
);
|
||||
|
||||
// binary search for the smallest index (largest font) that fits
|
||||
let lo: number;
|
||||
let hi: number;
|
||||
if (doesFit(warm)) {
|
||||
if (warm === 0 || !doesFit(warm - 1)) {
|
||||
return at(warm);
|
||||
}
|
||||
lo = 0;
|
||||
hi = warm - 1;
|
||||
} else {
|
||||
if (warm === steps) {
|
||||
return at(steps);
|
||||
}
|
||||
lo = warm + 1;
|
||||
hi = steps;
|
||||
}
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (doesFit(mid)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return at(lo);
|
||||
};
|
||||
|
||||
/**
|
||||
* The single source of truth for a sticky note's geometry: wraps the label at
|
||||
* the note's width, fits the font under the ceiling, grows the note past
|
||||
* `baseHeight` only when the text still overflows at the minimum size, and
|
||||
* positions the label inside. Pure — returns the updates for both elements.
|
||||
*/
|
||||
export const getStickyNoteLayout = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
textElement: ExcalidrawTextElement | null,
|
||||
opts: StickyNoteLayoutOpts = {},
|
||||
): StickyNoteLayout => {
|
||||
const baseWidth = getStickyNoteBaseWidth(container);
|
||||
const baseHeight = Math.max(
|
||||
opts.baseHeight ?? (container.baseHeight || container.height),
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
);
|
||||
const anchor = opts.anchor ?? "top";
|
||||
|
||||
if (!textElement) {
|
||||
// an empty note sits at its base height
|
||||
return {
|
||||
container: {
|
||||
...getPositionAfterHeightChange(container, baseHeight, anchor),
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
baseHeight,
|
||||
},
|
||||
text: null,
|
||||
};
|
||||
}
|
||||
|
||||
const originalText = opts.originalText ?? textElement.originalText;
|
||||
const baseFontSize = normalizeStickyNoteFontSize(
|
||||
opts.baseFontSize ?? textElement.baseFontSize ?? textElement.fontSize,
|
||||
);
|
||||
const fontSizeMin = Math.min(STICKY_NOTE_MIN_FONT_SIZE, baseFontSize);
|
||||
const maxWidth = Math.max(baseWidth - STICKY_NOTE_PADDING * 2, 1);
|
||||
const maxHeight = Math.max(baseHeight - STICKY_NOTE_BODY_INSET_Y, 0);
|
||||
const { fontFamily, lineHeight } = textElement;
|
||||
|
||||
const fit = (fontSize: number): FontFit => {
|
||||
const font = getFontString({ fontFamily, fontSize });
|
||||
const text = wrapText(originalText, font, maxWidth);
|
||||
return { text, fontSize, ...measureText(text, font, lineHeight) };
|
||||
};
|
||||
|
||||
const isBlank = !originalText.trim();
|
||||
const fitted: FontFit = isBlank
|
||||
? {
|
||||
text: "",
|
||||
fontSize: baseFontSize,
|
||||
...measureText(
|
||||
"",
|
||||
getFontString({ fontFamily, fontSize: baseFontSize }),
|
||||
lineHeight,
|
||||
),
|
||||
}
|
||||
: fitStickyNoteFont(fit, {
|
||||
baseFontSize,
|
||||
fontSizeMin,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
warmStart: textElement.fontSize,
|
||||
});
|
||||
|
||||
const height = isBlank
|
||||
? baseHeight
|
||||
: Math.max(baseHeight, fitted.height + STICKY_NOTE_BODY_INSET_Y);
|
||||
const nextContainer = {
|
||||
...getPositionAfterHeightChange(container, height, anchor),
|
||||
width: baseWidth,
|
||||
height,
|
||||
baseHeight,
|
||||
};
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
{ ...container, ...nextContainer },
|
||||
{
|
||||
...textElement,
|
||||
text: fitted.text,
|
||||
fontSize: fitted.fontSize,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
} as ExcalidrawTextElementWithContainer,
|
||||
NO_ELEMENTS,
|
||||
);
|
||||
|
||||
return {
|
||||
container: nextContainer,
|
||||
text: {
|
||||
text: fitted.text,
|
||||
fontSize: fitted.fontSize,
|
||||
baseFontSize,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
x,
|
||||
y,
|
||||
angle: container.angle,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize intents for a sticky note — the §3.3 matrix of the plan. Every value
|
||||
* is absolute and derived from the gesture-start snapshot on each pointer-move,
|
||||
* so releasing Shift (or Alt) mid-gesture restores the original base height
|
||||
* and ceiling instead of keeping values a previous move scaled.
|
||||
*
|
||||
* - width-only gestures (free E/W, Stats W) preserve the base height
|
||||
* - height-changing gestures use the requested height as the new base
|
||||
* - proportional gestures (Shift on any handle, aspect-locked multi-select,
|
||||
* Stats group) additionally scale the font ceiling with the note
|
||||
* - flips preserve everything
|
||||
* - the content correction anchors at the edge the gesture holds still
|
||||
*/
|
||||
export const getStickyNoteResizeIntent = (
|
||||
/** the note with the requested geometry already applied */
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
originalElementsMap: ElementsMap,
|
||||
handleDirection: TransformHandleDirection,
|
||||
{
|
||||
proportional,
|
||||
fromCenter,
|
||||
flip = false,
|
||||
}: { proportional: boolean; fromCenter: boolean; flip?: boolean },
|
||||
): Pick<StickyNoteLayoutOpts, "baseHeight" | "baseFontSize" | "anchor"> => {
|
||||
const origContainer = originalElementsMap.get(container.id);
|
||||
const origSticky = isStickyNoteElement(origContainer)
|
||||
? origContainer
|
||||
: container;
|
||||
const origText = getBoundTextElement(origSticky, originalElementsMap);
|
||||
const ceiling = origText
|
||||
? origText.baseFontSize ?? origText.fontSize
|
||||
: undefined;
|
||||
|
||||
if (flip) {
|
||||
return { baseHeight: origSticky.baseHeight, baseFontSize: ceiling };
|
||||
}
|
||||
|
||||
const changesHeight =
|
||||
proportional ||
|
||||
handleDirection.includes("n") ||
|
||||
handleDirection.includes("s");
|
||||
const scale =
|
||||
proportional && origSticky.width ? container.width / origSticky.width : 1;
|
||||
const isSideHandle = handleDirection.length === 1;
|
||||
|
||||
return {
|
||||
baseHeight: changesHeight ? container.height : origSticky.baseHeight,
|
||||
baseFontSize: ceiling === undefined ? undefined : ceiling * scale,
|
||||
anchor: fromCenter
|
||||
? "center"
|
||||
: handleDirection.includes("n")
|
||||
? "bottom"
|
||||
: proportional &&
|
||||
isSideHandle &&
|
||||
(handleDirection === "e" || handleDirection === "w")
|
||||
? // Shift+E/W holds the opposite side's midpoint, i.e. the vertical center
|
||||
"center"
|
||||
: "top",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies `getStickyNoteLayout` to a live scene, then runs the bound-arrow
|
||||
* pass unless the caller already owns one (`bindings: false`) or needs to
|
||||
* forward its context (`bindings: { simultaneouslyUpdated }` — arrows resized
|
||||
* in the same gesture must not be moved by the pass, `updateBoundElements`
|
||||
* skips them only when told).
|
||||
*/
|
||||
export const updateStickyNoteLayout = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
scene: Scene,
|
||||
{
|
||||
text,
|
||||
bindings,
|
||||
...layoutOpts
|
||||
}: StickyNoteLayoutOpts & {
|
||||
/** the label to lay out when it is an uncommitted clone (font actions clone before install) */
|
||||
text?: ExcalidrawTextElement | null;
|
||||
bindings?:
|
||||
| { simultaneouslyUpdated?: readonly NonDeletedExcalidrawElement[] }
|
||||
| false;
|
||||
} = {},
|
||||
): StickyNoteLayout => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const textElement =
|
||||
text === undefined ? getBoundTextElement(container, elementsMap) : text;
|
||||
const layout = getStickyNoteLayout(container, textElement, layoutOpts);
|
||||
|
||||
scene.mutateElement(container, layout.container);
|
||||
if (textElement && layout.text) {
|
||||
scene.mutateElement(textElement, layout.text);
|
||||
}
|
||||
if (bindings !== false && !container.isDeleted) {
|
||||
updateBoundElements(
|
||||
container as NonDeletedExcalidrawElement,
|
||||
scene,
|
||||
bindings || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return layout;
|
||||
};
|
||||
|
||||
const STICKY_NOTE_LAYOUT_INPUTS = {
|
||||
container: ["x", "y", "width", "baseHeight", "angle"],
|
||||
text: [
|
||||
"originalText",
|
||||
"baseFontSize",
|
||||
"fontFamily",
|
||||
"lineHeight",
|
||||
"textAlign",
|
||||
"verticalAlign",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const hasStickyNoteLayoutInputChanged = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
textElement: ExcalidrawTextElement | null,
|
||||
prevElementsMap: ElementsMap,
|
||||
) => {
|
||||
const prevContainer = prevElementsMap.get(container.id);
|
||||
const prevText = textElement ? prevElementsMap.get(textElement.id) : null;
|
||||
if (
|
||||
!prevContainer ||
|
||||
(textElement && !prevText) ||
|
||||
(!textElement &&
|
||||
prevContainer.boundElements?.some(({ type }) => type === "text"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
STICKY_NOTE_LAYOUT_INPUTS.container.some(
|
||||
(key) => (prevContainer as any)[key] !== container[key],
|
||||
) ||
|
||||
(!!textElement &&
|
||||
STICKY_NOTE_LAYOUT_INPUTS.text.some(
|
||||
(key) => (prevText as any)[key] !== textElement[key],
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies `getStickyNoteLayout` inside an immutable elements array (property
|
||||
* actions, paste styles, restore): the notes among `affectedIds` — or the
|
||||
* notes whose labels are — get both halves merged into their clones. Elements
|
||||
* that need no change keep their identity and version; when `prevElementsMap`
|
||||
* is given, notes whose layout inputs did not change skip the fit entirely.
|
||||
* Bound arrows are the caller's job once the result is installed.
|
||||
*/
|
||||
export const relayoutStickyNotes = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
affectedIds: ReadonlySet<ExcalidrawElement["id"]>,
|
||||
opts?: { prevElementsMap?: ElementsMap },
|
||||
): readonly T[] => {
|
||||
if (!affectedIds.size) {
|
||||
return elements;
|
||||
}
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const containerIds = new Set<ExcalidrawElement["id"]>();
|
||||
for (const id of affectedIds) {
|
||||
const element = elementsMap.get(id);
|
||||
if (!element || element.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (isStickyNoteElement(element)) {
|
||||
containerIds.add(element.id);
|
||||
} else if (isTextElement(element) && element.containerId) {
|
||||
const container = elementsMap.get(element.containerId);
|
||||
if (container && !container.isDeleted && isStickyNoteElement(container)) {
|
||||
containerIds.add(container.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!containerIds.size) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const replacements = new Map<ExcalidrawElement["id"], T>();
|
||||
for (const id of containerIds) {
|
||||
const container = elementsMap.get(id) as ExcalidrawStickyNoteElement;
|
||||
const textElement = getBoundTextElement(container, elementsMap);
|
||||
if (
|
||||
opts?.prevElementsMap &&
|
||||
!hasStickyNoteLayoutInputChanged(
|
||||
container,
|
||||
textElement,
|
||||
opts.prevElementsMap,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const layout = getStickyNoteLayout(container, textElement);
|
||||
const nextContainer = newElementWith(container, layout.container);
|
||||
if (nextContainer !== container) {
|
||||
replacements.set(id, nextContainer as unknown as T);
|
||||
}
|
||||
if (textElement && layout.text) {
|
||||
const nextText = newElementWith(textElement, layout.text);
|
||||
if (nextText !== textElement) {
|
||||
replacements.set(textElement.id, nextText as unknown as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!replacements.size) {
|
||||
return elements;
|
||||
}
|
||||
return elements.map((element) => replacements.get(element.id) ?? element);
|
||||
};
|
||||
@@ -19,6 +19,8 @@ import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||
|
||||
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "./versioning";
|
||||
|
||||
import {
|
||||
syncInvalidIndicesImmutable,
|
||||
hashElementsVersion,
|
||||
@@ -499,6 +501,7 @@ export class StoreDelta {
|
||||
public readonly id: string,
|
||||
public readonly elements: ElementsDelta,
|
||||
public readonly appState: AppStateDelta,
|
||||
public readonly schemaVersion: number = CURRENT_SCHEMA_VERSION,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -513,7 +516,7 @@ export class StoreDelta {
|
||||
id: randomId(),
|
||||
},
|
||||
) {
|
||||
return new this(opts.id, elements, appState);
|
||||
return new this(opts.id, elements, appState, CURRENT_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -535,26 +538,31 @@ export class StoreDelta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a store delta instance from a DTO.
|
||||
* Restore a store delta instance from a DTO, lifting the contained element
|
||||
* partials to the current schema version.
|
||||
*/
|
||||
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||
const { id, elements, appState } = storeDeltaDTO;
|
||||
return new this(
|
||||
id,
|
||||
ElementsDelta.restore(elements),
|
||||
ElementsDelta.restore(elements, getSchemaVersion(storeDeltaDTO)),
|
||||
AppStateDelta.restore(appState),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and load the delta from the remote payload.
|
||||
* Parse and load the delta from the remote payload, lifting the contained
|
||||
* element partials to the current schema version.
|
||||
*/
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
public static load(storeDeltaDTO: DTO<StoreDelta>) {
|
||||
const {
|
||||
id,
|
||||
appState: { delta: appStateDelta },
|
||||
} = storeDeltaDTO;
|
||||
const elements = ElementsDelta.restore(
|
||||
storeDeltaDTO.elements,
|
||||
getSchemaVersion(storeDeltaDTO),
|
||||
);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, appState);
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_PADDING,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
@@ -12,8 +14,6 @@ import {
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
@@ -21,12 +21,15 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getPositionAfterHeightChange } from "./sizeHelpers";
|
||||
|
||||
import { updateStickyNoteLayout } from "./stickyNote";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
@@ -52,6 +55,14 @@ export const redrawTextBoundingBox = (
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (container && isStickyNoteElement(container)) {
|
||||
// the sticky fit owns both halves (label + note geometry). `textElement`
|
||||
// may be an uncommitted clone (font actions clone before install), so it
|
||||
// is passed explicitly instead of looked up in the scene
|
||||
updateStickyNoteLayout(container, scene, { text: textElement });
|
||||
return;
|
||||
}
|
||||
|
||||
let maxWidth = undefined;
|
||||
|
||||
if (!isProdEnv()) {
|
||||
@@ -147,7 +158,14 @@ export const handleBindTextResize = (
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
shouldResizeFromCenter = false,
|
||||
flipByY = false,
|
||||
) => {
|
||||
if (isStickyNoteElement(container)) {
|
||||
// resize callers pass their intents to `updateStickyNoteLayout` directly
|
||||
// and own the bound-arrow pass; this is the fallback for generic callers
|
||||
updateStickyNoteLayout(container, scene, { bindings: false });
|
||||
return;
|
||||
}
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@@ -192,24 +210,24 @@ export const handleBindTextResize = (
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
// Crossing the opposite edge swaps the anchor for text-driven growth.
|
||||
const shouldResizeFromTop =
|
||||
transformHandleType === "n" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw";
|
||||
(transformHandleType === "n" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw") !== flipByY;
|
||||
|
||||
let offsetY = 0;
|
||||
if (!isArrowElement(container)) {
|
||||
if (shouldResizeFromCenter) {
|
||||
offsetY = diff / 2;
|
||||
} else if (shouldResizeFromTop) {
|
||||
offsetY = diff;
|
||||
}
|
||||
}
|
||||
scene.mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: container.y - offsetY,
|
||||
...(!isArrowElement(container) &&
|
||||
getPositionAfterHeightChange(
|
||||
container,
|
||||
containerHeight,
|
||||
shouldResizeFromCenter
|
||||
? "center"
|
||||
: shouldResizeFromTop
|
||||
? "bottom"
|
||||
: "top",
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,6 +268,18 @@ export const computeBoundTextPosition = (
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else if (isStickyNoteElement(container)) {
|
||||
// a note's label body ends above the creation-date footer, but a label
|
||||
// centered in that body sits visibly high — center it in the whole
|
||||
// padded note while it stays clear of the footer, and only push it up
|
||||
// against the body's bottom once it would overlap
|
||||
const paddedHeight = container.height - STICKY_NOTE_PADDING * 2;
|
||||
y =
|
||||
containerCoords.y +
|
||||
Math.min(
|
||||
(paddedHeight - boundTextElement.height) / 2,
|
||||
maxContainerHeight - boundTextElement.height,
|
||||
);
|
||||
} else {
|
||||
y =
|
||||
containerCoords.y +
|
||||
@@ -266,10 +296,17 @@ export const computeBoundTextPosition = (
|
||||
const angle = (container.angle ?? 0) as Radians;
|
||||
|
||||
if (angle !== 0) {
|
||||
const contentCenter = pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
// A sticky's footer makes its body asymmetric. The body still rotates
|
||||
// about the note's center, rather than about its own (higher) center.
|
||||
const contentCenter = isStickyNoteElement(container)
|
||||
? pointFrom(
|
||||
container.x + container.width / 2,
|
||||
container.y + container.height / 2,
|
||||
)
|
||||
: pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
const textCenter = pointFrom(
|
||||
x + boundTextElement.width / 2,
|
||||
y + boundTextElement.height / 2,
|
||||
@@ -333,9 +370,12 @@ export const getContainerElement = <
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The point a text bound to this container centers on — and, for arrows, the
|
||||
* point the text tool snaps a new label to.
|
||||
*/
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!isArrowElement(container)) {
|
||||
@@ -344,38 +384,21 @@ export const getContainerCenter = (
|
||||
y: container.y + container.height / 2,
|
||||
};
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
container,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length % 2 === 1) {
|
||||
const index = Math.floor(container.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
container,
|
||||
container.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
return { x: midPoint[0], y: midPoint[1] };
|
||||
}
|
||||
const index = container.points.length / 2 - 1;
|
||||
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
||||
container,
|
||||
elementsMap,
|
||||
appState,
|
||||
)[index];
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
||||
return { x: center[0], y: center[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: ExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
const padding = isStickyNoteElement(container)
|
||||
? STICKY_NOTE_PADDING
|
||||
: BOUND_TEXT_PADDING;
|
||||
let offsetX = padding;
|
||||
let offsetY = padding;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
@@ -455,6 +478,7 @@ export const suppportsHorizontalAlign = (
|
||||
|
||||
const VALID_CONTAINER_TYPES = new Set([
|
||||
"rectangle",
|
||||
"stickynote",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"arrow",
|
||||
@@ -506,7 +530,13 @@ export const getBoundTextMaxWidth = (
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
return (
|
||||
width -
|
||||
(isStickyNoteElement(container)
|
||||
? STICKY_NOTE_PADDING
|
||||
: BOUND_TEXT_PADDING) *
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
@@ -514,6 +544,10 @@ export const getBoundTextMaxHeight = (
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const { height } = container;
|
||||
if (isStickyNoteElement(container)) {
|
||||
// the label body ends above the creation-date footer
|
||||
return Math.max(0, height - STICKY_NOTE_BODY_INSET_Y);
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
@@ -550,3 +584,5 @@ export const getTextFromElements = (
|
||||
.join(separator);
|
||||
return text;
|
||||
};
|
||||
|
||||
export const DEFAULT_BOUND_TEXT_LABEL_POSITION = 0.5;
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
getLineHeight,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
isTransparent,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkOptional } from "@excalidraw/common/utility-types";
|
||||
@@ -22,19 +25,28 @@ import { bindBindingElement } from "./binding";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
newImageElement,
|
||||
newLinearElement,
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
type ElementConstructorOpts,
|
||||
newStickyNoteElement,
|
||||
normalizeStickyNoteGeometry,
|
||||
} from "./newElement";
|
||||
import { normalizeStickyNoteStrokeColor } from "./stickyNote";
|
||||
import { measureText, normalizeText } from "./textMeasurements";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { isArrowElement, isStickyNoteElement } from "./typeChecks";
|
||||
|
||||
import { syncInvalidIndices } from "./fractionalIndex";
|
||||
|
||||
import { redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
redrawTextBoundingBox,
|
||||
} from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
@@ -63,8 +75,19 @@ import type {
|
||||
Ordered,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Options for elements generated from a skeleton fragment (bound labels and
|
||||
* binding endpoints). Such elements are always constructed fresh, hence they
|
||||
* never carry a creation time of their own.
|
||||
*/
|
||||
type FragmentConstructorOpts = MarkOptional<
|
||||
Omit<ElementConstructorOpts, "created">,
|
||||
"x" | "y"
|
||||
>;
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
@@ -75,7 +98,7 @@ export type ValidLinearElement = {
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & FragmentConstructorOpts;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
@@ -115,9 +138,9 @@ export type ValidLinearElement = {
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
Partial<Omit<ExcalidrawTextElement, "created">>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
FragmentConstructorOpts;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
@@ -157,9 +180,9 @@ export type ValidLinearElement = {
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
Partial<Omit<ExcalidrawTextElement, "created">>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
FragmentConstructorOpts;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
@@ -172,9 +195,21 @@ export type ValidContainer =
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & FragmentConstructorOpts;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
/**
|
||||
* A sticky note: an always-filled note whose label auto-fits. `label.fontSize`
|
||||
* is the font ceiling the fit shrinks from; the note grows past its height
|
||||
* (kept as `baseHeight`) only once the label hits the minimum font size.
|
||||
*/
|
||||
export type ValidStickyNote = {
|
||||
type: "stickynote";
|
||||
id?: ExcalidrawStickyNoteElement["id"];
|
||||
label?: Extract<ValidContainer, { label?: unknown }>["label"];
|
||||
} & ElementConstructorOpts &
|
||||
Partial<Pick<ExcalidrawStickyNoteElement, "baseHeight">>;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
@@ -186,6 +221,7 @@ export type ExcalidrawElementSkeleton =
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidStickyNote
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
@@ -220,9 +256,19 @@ const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
textProps: { text: string } & FragmentConstructorOpts,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// a note and its label share one ink: a label that sets its own color
|
||||
// gives it to the note (the footer paints with it); transparent falls
|
||||
// back to the note's
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? normalizeStickyNoteStrokeColor(
|
||||
textProps.strokeColor && !isTransparent(textProps.strokeColor)
|
||||
? textProps.strokeColor
|
||||
: container.strokeColor,
|
||||
)
|
||||
: null;
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -230,7 +276,10 @@ const bindTextToContainer = (
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
strokeColor: stickyInk ?? (textProps.strokeColor || container.strokeColor),
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
@@ -238,6 +287,9 @@ const bindTextToContainer = (
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
...(stickyInk && stickyInk !== container.strokeColor
|
||||
? { strokeColor: stickyInk }
|
||||
: null),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, scene);
|
||||
@@ -525,11 +577,14 @@ export const convertToExcalidrawElements = (
|
||||
const oldToNewElementIdMap = new Map<string, string>();
|
||||
|
||||
// Create individual elements
|
||||
// regenerated ids mean new instances, hence a fresh creation time as well
|
||||
const created = getUpdatedTimestamp();
|
||||
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
const originalId = element.id;
|
||||
if (opts?.regenerateIds !== false) {
|
||||
Object.assign(element, { id: randomId() });
|
||||
Object.assign(element, { id: randomId(), created });
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
@@ -628,10 +683,32 @@ export const convertToExcalidrawElements = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "iframe":
|
||||
case "freedraw": {
|
||||
excalidrawElement = newFreeDrawElement({ ...element });
|
||||
break;
|
||||
}
|
||||
case "iframe": {
|
||||
excalidrawElement = newIframeElement({ ...element });
|
||||
break;
|
||||
}
|
||||
case "stickynote": {
|
||||
const width = element.width || DEFAULT_STICKY_NOTE_SIZE;
|
||||
const height = element.height || DEFAULT_STICKY_NOTE_SIZE;
|
||||
// finalized geometry (min size, baseHeight ≤ height) — only a
|
||||
// pointer-down draft is exempt from it
|
||||
excalidrawElement = normalizeStickyNoteGeometry(
|
||||
newStickyNoteElement({
|
||||
...element,
|
||||
type: "stickynote",
|
||||
width,
|
||||
height,
|
||||
baseHeight: element.baseHeight ?? height,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
excalidrawElement = newEmbeddableElement({ ...element });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -668,8 +745,11 @@ export const convertToExcalidrawElements = (
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "stickynote":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
// for a sticky note this runs the sticky fit (via
|
||||
// `redrawTextBoundingBox`): the label's font size becomes its ceiling
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ExcalidrawLineElement,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = <T extends ExcalidrawElement>(
|
||||
@@ -69,6 +70,12 @@ export const isTextElement = <T extends ExcalidrawElement>(
|
||||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isStickyNoteElement = <T extends ExcalidrawElement>(
|
||||
element: T | null | undefined,
|
||||
): element is T & ExcalidrawStickyNoteElement => {
|
||||
return element != null && element.type === "stickynote";
|
||||
};
|
||||
|
||||
export const isFrameElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawFrameElement => {
|
||||
@@ -182,6 +189,7 @@ export const isBindableElement = <T extends ExcalidrawElement>(
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
@@ -199,6 +207,7 @@ export const isRectanguloidElement = <T extends ExcalidrawElement>(
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
@@ -217,6 +226,7 @@ export const isRectangularElement = <T extends ExcalidrawElement>(
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "iframe" ||
|
||||
@@ -235,6 +245,7 @@ export const isTextBindableContainer = <T extends ExcalidrawElement>(
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
isArrowElement(element))
|
||||
@@ -252,6 +263,7 @@ export const isExcalidrawElement = (
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "ellipse":
|
||||
@@ -276,6 +288,7 @@ export const isFlowchartNodeElement = <T extends ExcalidrawElement>(
|
||||
): element is T & ExcalidrawFlowchartNodeElement => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond"
|
||||
);
|
||||
@@ -313,7 +326,10 @@ export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "image";
|
||||
|
||||
export const isUsingProportionalRadius = (type: string) =>
|
||||
type === "line" || type === "arrow" || type === "diamond";
|
||||
type === "line" ||
|
||||
type === "arrow" ||
|
||||
type === "diamond" ||
|
||||
type === "stickynote";
|
||||
|
||||
export const canApplyRoundnessTypeToElement = (
|
||||
roundnessType: RoundnessType,
|
||||
@@ -397,6 +413,7 @@ export const canBecomePolygon = (
|
||||
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
|
||||
switch (type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "arrow":
|
||||
|
||||
@@ -76,6 +76,11 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
boundElements: readonly BoundElement[] | null;
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
/** Client wall-clock creation time in epoch milliseconds; null if unknown.
|
||||
Preserved for this element's lifetime, including edits and undo/redo,
|
||||
and excluded from `ElementUpdate` (mutateElement / newElementWith).
|
||||
Duplicating an element starts a new lifetime. Not an ordering clock. */
|
||||
created: number | null;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
customData?: Record<string, any>;
|
||||
@@ -89,6 +94,16 @@ export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawStickyNoteElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "stickynote";
|
||||
/**
|
||||
* The height the user set, from which the layout derives `height`: the
|
||||
* note grows above it to fit its label and never shrinks below it
|
||||
*/
|
||||
baseHeight: number;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
||||
type: "diamond";
|
||||
};
|
||||
@@ -185,11 +200,13 @@ export type ExcalidrawGenericElement =
|
||||
|
||||
export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
@@ -205,6 +222,7 @@ export type ExcalidrawRectanguloidElement =
|
||||
*/
|
||||
export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
@@ -237,10 +255,19 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
type: "text";
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
/**
|
||||
* The font size the user picked, from which the layout derives `fontSize`.
|
||||
* Today only sticky note labels have one: the auto-fit shrinks below it
|
||||
* and never above it (compare `baseHeight`, which the note grows above).
|
||||
* `null` for every other text. Read it through `getBaseFontSize` —
|
||||
* generic binding repair can detach a label without clearing this, so
|
||||
* the container decides its meaning.
|
||||
*/
|
||||
baseFontSize: number | null;
|
||||
text: string;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
containerId: ExcalidrawTextContainer["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* If `true` the width will fit the text. If `false`, the text will
|
||||
@@ -254,10 +281,18 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
*/
|
||||
lineHeight: number & { _brand: "unitlessLineHeight" };
|
||||
/**
|
||||
* Position of text bound to a linear element (such as an arrow),
|
||||
* expressed as a normalized arc-length parameter (0–1) along the
|
||||
* container's whole path. Independent of how the path is segmented,
|
||||
* so it survives midpoint insertion and other geometry changes.
|
||||
* */
|
||||
labelPosition?: number | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
@@ -269,6 +304,7 @@ export type ExcalidrawBindableElement =
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawArrowElement;
|
||||
|
||||
@@ -40,13 +40,14 @@ import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { hitElementItself, isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { isElbowArrow, isRectangularElement } from "./typeChecks";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
normalizeFixedPoint,
|
||||
} from "./binding";
|
||||
import { getStickyNoteCornerRadius } from "./stickyNote";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
@@ -59,6 +60,8 @@ import type {
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export type LinearPathSegment = LineSegment<GlobalPoint> | Curve<GlobalPoint>;
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
@@ -125,7 +128,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
@@ -133,8 +136,8 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element, elementsMap);
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
const lines: LineSegment<GlobalPoint>[] = [];
|
||||
const curves: Curve<GlobalPoint>[] = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
@@ -193,12 +196,43 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
const shape: ElementShape = [lines, curves];
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
export function getLinearElementPathSegments(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearPathSegment[] {
|
||||
// For now, model elbow arrows as their unrounded logical path. Rounded
|
||||
// joints can be incorporated once the path model supports mixed straight
|
||||
// and curved segments.
|
||||
if (isElbowArrow(element)) {
|
||||
return element.points
|
||||
.slice(1)
|
||||
.map((point, index) =>
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.points[index][0],
|
||||
element.y + element.points[index][1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Non-elbow paths currently contain only one segment type. Mixed paths
|
||||
// should consume an ordered operation stream instead of these type buckets.
|
||||
return curves.length > 0 ? curves : lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves **unrotated**.
|
||||
@@ -210,17 +244,17 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
let radius =
|
||||
element.type === "stickynote"
|
||||
? getStickyNoteCornerRadius(element)
|
||||
: getCornerRadius(Math.min(element.width, element.height), element);
|
||||
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
@@ -422,7 +456,7 @@ export function getDiamondBaseCorners(
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
@@ -431,10 +465,20 @@ export function deconstructDiamondElement(
|
||||
|
||||
const baseCorners = getDiamondBaseCorners(element, offset);
|
||||
|
||||
const corners = baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
|
||||
);
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The schema version the current in-memory element shape corresponds to.
|
||||
*/
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
type SchemaMigration = {
|
||||
// Upgrades a full element from schema version n to n + 1.
|
||||
element: (element: any) => any;
|
||||
// Upgrades a partial element (durable undo/redo).
|
||||
elementPartial: (partial: any) => any;
|
||||
};
|
||||
|
||||
const identity = <T>(value: T): T => value;
|
||||
|
||||
/**
|
||||
* migrations[n] upgrades data from schema version to n + 1.
|
||||
*
|
||||
* NOTE: Index 0 migrates legacy (unversioned) data to version 1.
|
||||
*
|
||||
* IMPORTANT: data of unknown provenance (API input, clipboard from older
|
||||
* builds etc.) is considered 'legacy' v0!
|
||||
*/
|
||||
const migrations: Record<number, SchemaMigration> = {
|
||||
0: { element: identity, elementPartial: identity },
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the schema version indicated by a container.
|
||||
*/
|
||||
export const getSchemaVersion = (source: unknown): number => {
|
||||
const schemaVersion = (source as { schemaVersion?: unknown } | null)
|
||||
?.schemaVersion;
|
||||
|
||||
return typeof schemaVersion === "number" && Number.isFinite(schemaVersion)
|
||||
? schemaVersion
|
||||
: 0;
|
||||
};
|
||||
|
||||
const runMigrations = <T>(
|
||||
kind: keyof SchemaMigration,
|
||||
value: T,
|
||||
fromVersion: number,
|
||||
): T => {
|
||||
let version = fromVersion;
|
||||
let migrated: any = value;
|
||||
|
||||
// NOTE: data from a newer client (fromVersion > current) is returned as-is;
|
||||
// unknown properties are preserved down the line for forward-compatibility
|
||||
while (version < CURRENT_SCHEMA_VERSION) {
|
||||
const migration = migrations[version];
|
||||
if (!migration) {
|
||||
break;
|
||||
}
|
||||
migrated = migration[kind](migrated);
|
||||
version += 1;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lifts a full element from `fromVersion` up to
|
||||
* {@link CURRENT_SCHEMA_VERSION} by running each migration in sequence.
|
||||
*/
|
||||
export const migrateElement = <T>(element: T, fromVersion: number): T =>
|
||||
runMigrations("element", element, fromVersion);
|
||||
|
||||
/**
|
||||
* Lifts all elements of a container from `fromVersion` up to
|
||||
* {@link CURRENT_SCHEMA_VERSION}.
|
||||
*/
|
||||
export const migrateElements = <T>(
|
||||
elements: readonly T[],
|
||||
fromVersion: number,
|
||||
): readonly T[] =>
|
||||
fromVersion >= CURRENT_SCHEMA_VERSION
|
||||
? elements
|
||||
: elements.map((element) => migrateElement(element, fromVersion));
|
||||
|
||||
/**
|
||||
* Lifts a partial element shape (as contained in deltas) from `fromVersion`
|
||||
* up to {@link CURRENT_SCHEMA_VERSION}.
|
||||
*/
|
||||
export const migrateElementPartial = <T>(partial: T, fromVersion: number): T =>
|
||||
fromVersion >= CURRENT_SCHEMA_VERSION
|
||||
? partial
|
||||
: runMigrations("elementPartial", partial, fromVersion);
|
||||
@@ -480,6 +480,9 @@ describe("binding for simple arrows", () => {
|
||||
|
||||
const elX = handles[0] + handles[2] / 2;
|
||||
const elY = handles[1] + handles[3] / 2;
|
||||
// move first, as a real pointer would — the hover state from the spot
|
||||
// where arrow creation finished must clear before the pointerdown
|
||||
mouse.moveTo(elX, elY);
|
||||
mouse.downAt(elX, elY);
|
||||
mouse.moveTo(300, 400);
|
||||
mouse.up();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,292 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
import type {
|
||||
ExcalidrawTextElement,
|
||||
FractionalIndex,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
import { mutateElement, newElementWith } from "../src/mutateElement";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("visible changes", () => {
|
||||
const apply = (before: SceneElementsMap, after: SceneElementsMap) =>
|
||||
ElementsDelta.calculate(before, after).applyTo(before, before, {
|
||||
excludedProperties: new Set(["version", "versionNonce"]),
|
||||
});
|
||||
|
||||
const boundText = (
|
||||
type: "rectangle" | "stickynote" | "arrow",
|
||||
text = "",
|
||||
) => {
|
||||
const container = API.createElement({
|
||||
type,
|
||||
id: "container",
|
||||
index: "a0" as FractionalIndex,
|
||||
width: 250,
|
||||
height: type === "arrow" ? 0 : 250,
|
||||
baseHeight: type === "stickynote" ? 250 : undefined,
|
||||
points:
|
||||
type === "arrow" ? [pointFrom(0, 0), pointFrom(250, 0)] : undefined,
|
||||
boundElements: [{ id: "label", type: "text" }],
|
||||
});
|
||||
const label: ExcalidrawTextElement = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
index: "a1" as FractionalIndex,
|
||||
containerId: container.id,
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
}),
|
||||
// API.createElement substitutes "test" for an empty string.
|
||||
text,
|
||||
originalText: text,
|
||||
};
|
||||
const before = arrayToMap([container, label]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(container, { boundElements: [] }),
|
||||
newElementWith(label, { isDeleted: true }),
|
||||
]) as SceneElementsMap;
|
||||
return { container, label, before, after };
|
||||
};
|
||||
|
||||
it.each(["rectangle", "stickynote"] as const)(
|
||||
"ignores empty-label deletion and restoration on a %s",
|
||||
(type) => {
|
||||
const { before, after, container, label } = boundText(type);
|
||||
const [deleted, deletionIsVisible] = apply(before, after);
|
||||
expect(deletionIsVisible).toBe(false);
|
||||
expect(deleted.get(label.id)?.isDeleted).toBe(true);
|
||||
expect(deleted.get(container.id)?.boundElements).toEqual([]);
|
||||
|
||||
const [restored, restorationIsVisible] = apply(after, before);
|
||||
expect(restorationIsVisible).toBe(false);
|
||||
expect(restored.get(label.id)?.isDeleted).toBe(false);
|
||||
expect(restored.get(container.id)?.boundElements).toEqual([
|
||||
{ id: label.id, type: "text" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps deletion and restoration of nonempty labels visible", () => {
|
||||
const { before, after } = boundText("stickynote", "hello");
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
expect(apply(after, before)[1]).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["excluded", "already applied"])(
|
||||
"ignores %s properties when deciding visibility",
|
||||
(mode) => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
const before = arrayToMap([element]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(element, { strokeColor: "red" }),
|
||||
]) as SceneElementsMap;
|
||||
const delta = ElementsDelta.calculate(before, after);
|
||||
const current = mode === "excluded" ? before : after;
|
||||
const [elements, isVisible] = delta.applyTo(current, current, {
|
||||
excludedProperties: new Set([
|
||||
"version",
|
||||
"versionNonce",
|
||||
...(mode === "excluded" ? ["strokeColor" as const] : []),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(elements.get(element.id)?.strokeColor).toBe(
|
||||
current.get(element.id)?.strokeColor,
|
||||
);
|
||||
expect(isVisible).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps snapshot restoration visible for a metadata-only update", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
const before = arrayToMap([element]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(element, { version: element.version + 1 }),
|
||||
]) as SceneElementsMap;
|
||||
const delta = ElementsDelta.calculate(before, after);
|
||||
const [elements, isVisible] = delta.applyTo(
|
||||
new Map() as SceneElementsMap,
|
||||
before,
|
||||
);
|
||||
|
||||
expect(elements.get(element.id)?.isDeleted).toBe(false);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an arrow's empty-label gap visible", () => {
|
||||
const { before, after } = boundText("arrow");
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
expect(apply(after, before)[1]).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a container resized by restoring an empty label", () => {
|
||||
const { container, label } = boundText("rectangle");
|
||||
const smallContainer = newElementWith(container, {
|
||||
width: 20,
|
||||
height: 10,
|
||||
boundElements: [],
|
||||
});
|
||||
const before = arrayToMap([
|
||||
smallContainer,
|
||||
newElementWith(label, { isDeleted: true }),
|
||||
]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(smallContainer, {
|
||||
boundElements: [{ id: label.id, type: "text" }],
|
||||
}),
|
||||
newElementWith(label, { isDeleted: false }),
|
||||
]) as SceneElementsMap;
|
||||
|
||||
const [elements, isVisible] = apply(before, after);
|
||||
expect(elements.get(container.id)?.height).toBeGreaterThan(10);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a bound label repositioned by a container's redraw", () => {
|
||||
const { container, label } = boundText("rectangle", "hello");
|
||||
const misplacedLabel = newElementWith(label, { x: -100, y: -100 });
|
||||
const before = arrayToMap([
|
||||
container,
|
||||
misplacedLabel,
|
||||
]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(container, { version: container.version + 1 }),
|
||||
misplacedLabel,
|
||||
]) as SceneElementsMap;
|
||||
|
||||
const [elements, isVisible] = apply(before, after);
|
||||
expect(elements.get(container.id)?.height).toBe(container.height);
|
||||
expect(elements.get(label.id)?.x).not.toBe(-100);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a bound arrow repositioned by a container's redraw", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "container",
|
||||
index: "a0" as FractionalIndex,
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
index: "a1" as FractionalIndex,
|
||||
x: 200,
|
||||
y: 50,
|
||||
points: [pointFrom(0, 0), pointFrom(100, 0)],
|
||||
startBinding: {
|
||||
elementId: container.id,
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
const before = arrayToMap([container, arrow]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(container, { version: container.version + 1 }),
|
||||
arrow,
|
||||
]) as SceneElementsMap;
|
||||
|
||||
const [elements, isVisible] = apply(before, after);
|
||||
expect(elements.get(container.id)?.width).toBe(container.width);
|
||||
expect(elements.get(arrow.id)?.x).not.toBe(200);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["test", "development", "production"])(
|
||||
"guards against untracked layout mutations in %s mode",
|
||||
(mode) => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "changed",
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
const unrelated = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "unrelated",
|
||||
index: "a1" as FractionalIndex,
|
||||
});
|
||||
const before = arrayToMap([element, unrelated]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(element, { strokeColor: "red" }),
|
||||
unrelated,
|
||||
]) as SceneElementsMap;
|
||||
// Simulate a future layout dependency missing from idsToCheck.
|
||||
// Mutate the shared instance so the guard must snapshot version values.
|
||||
const redraw = vi
|
||||
.spyOn(ElementsDelta, "redrawElements")
|
||||
.mockImplementationOnce((elements) => {
|
||||
mutateElement(elements.get(unrelated.id)!, elements, {
|
||||
x: unrelated.x + 1,
|
||||
});
|
||||
return elements;
|
||||
});
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
vi.stubEnv("MODE", mode);
|
||||
|
||||
try {
|
||||
if (mode === "production") {
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(() => apply(before, after)).toThrow(
|
||||
'Redrawn element "unrelated" is missing from idsToCheck',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
redraw.mockRestore();
|
||||
consoleError.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an empty text element's arrow bindings visible", () => {
|
||||
const label: ExcalidrawTextElement = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
index: "a0" as FractionalIndex,
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
}),
|
||||
text: "",
|
||||
originalText: "",
|
||||
};
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
index: "a1" as FractionalIndex,
|
||||
startBinding: {
|
||||
elementId: label.id,
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
const before = arrayToMap([label, arrow]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(label, { isDeleted: true, boundElements: [] }),
|
||||
newElementWith(arrow, { startBinding: null }),
|
||||
]) as SceneElementsMap;
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not throw when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ORIG_ID,
|
||||
ROUNDNESS,
|
||||
isPrimitive,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
@@ -24,7 +25,11 @@ import {
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
||||
import {
|
||||
deepCopyElement,
|
||||
duplicateElement,
|
||||
duplicateElements,
|
||||
} from "../src/duplicate";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
@@ -43,6 +48,24 @@ const assertCloneObjects = (source: any, clone: any) => {
|
||||
};
|
||||
|
||||
describe("duplicating single elements", () => {
|
||||
it.each([123, 0, null])(
|
||||
"preserves created=%s on a deep copy and resets it for a new instance",
|
||||
(created) => {
|
||||
const element = API.createElement({ type: "rectangle", created });
|
||||
const cloned = deepCopyElement(element);
|
||||
const duplicate = duplicateElement(null, new Map(), element);
|
||||
|
||||
expect(cloned).toEqual(element);
|
||||
expect(duplicate).toMatchObject({
|
||||
created: getUpdatedTimestamp(),
|
||||
version: element.version,
|
||||
versionNonce: element.versionNonce,
|
||||
});
|
||||
expect(duplicate.id).not.toBe(element.id);
|
||||
expect(element.created).toBe(created);
|
||||
},
|
||||
);
|
||||
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
KEYS,
|
||||
@@ -26,11 +27,12 @@ import {
|
||||
|
||||
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { wrapText } from "../src";
|
||||
import { hasBoundingBox, wrapText } from "../src";
|
||||
import * as textElementUtils from "../src/textElement";
|
||||
import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
|
||||
import { LinearElementEditor } from "../src";
|
||||
import { newArrowElement } from "../src";
|
||||
import { getLinearElementPathSegments } from "../src/utils";
|
||||
|
||||
import {
|
||||
getTextEditor,
|
||||
@@ -213,14 +215,16 @@ describe("Test Linear Elements", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
|
||||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`10`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
[
|
||||
@@ -240,6 +244,53 @@ describe("Test Linear Elements", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("should hide the bounding box while creating or dragging a point", () => {
|
||||
const line = createTwoPointerLinearElement("line");
|
||||
const editorInterface = {
|
||||
userAgent: { isMobileDevice: false },
|
||||
} as Parameters<typeof hasBoundingBox>[2];
|
||||
|
||||
fireEvent.pointerDown(interactiveCanvas, {
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
});
|
||||
fireEvent.pointerMove(interactiveCanvas, {
|
||||
clientX: midpoint[0] + delta,
|
||||
clientY: midpoint[1] + delta,
|
||||
});
|
||||
|
||||
expect(line.points.length).toBe(3);
|
||||
expect(h.state.selectedLinearElement?.isDragging).toBe(true);
|
||||
expect(hasBoundingBox([line], h.state, editorInterface)).toBe(false);
|
||||
|
||||
fireEvent.pointerUp(interactiveCanvas, {
|
||||
clientX: midpoint[0] + delta,
|
||||
clientY: midpoint[1] + delta,
|
||||
});
|
||||
|
||||
const addedPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
line,
|
||||
1,
|
||||
h.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
fireEvent.pointerDown(interactiveCanvas, {
|
||||
clientX: addedPoint[0],
|
||||
clientY: addedPoint[1],
|
||||
});
|
||||
fireEvent.pointerMove(interactiveCanvas, {
|
||||
clientX: addedPoint[0] + 1,
|
||||
clientY: addedPoint[1] + 1,
|
||||
});
|
||||
|
||||
expect(h.state.selectedLinearElement?.isDragging).toBe(true);
|
||||
expect(hasBoundingBox([line], h.state, editorInterface)).toBe(false);
|
||||
|
||||
fireEvent.pointerUp(interactiveCanvas, {
|
||||
clientX: addedPoint[0],
|
||||
clientY: addedPoint[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow entering and exiting line editor via context menu", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
@@ -260,6 +311,46 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should allow editing an arrow from the context menu when its bound text appears earlier in the elements array", () => {
|
||||
const arrow = createTwoPointerLinearElement("arrow");
|
||||
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: "abc",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 20,
|
||||
});
|
||||
const updatedArrow = {
|
||||
...arrow,
|
||||
boundElements: [{ type: "text" as const, id: textElement.id }],
|
||||
};
|
||||
|
||||
// text element intentionally placed before its container in the array
|
||||
API.setElements([textElement, updatedArrow]);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: midpoint[0],
|
||||
clientY: midpoint[1],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit arrow")!),
|
||||
).not.toThrow();
|
||||
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(updatedArrow.id);
|
||||
});
|
||||
|
||||
it("should enter line editor via enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
@@ -335,36 +426,14 @@ describe("Test Linear Elements", () => {
|
||||
await getTextEditor();
|
||||
});
|
||||
|
||||
describe("Arrowhead toggle on endpoint dblclick", () => {
|
||||
// the toggle replaces the element (immutable update), so always re-read
|
||||
// the arrow from the scene instead of holding on to a stale reference
|
||||
const getArrow = () => h.elements[0] as ExcalidrawArrowElement;
|
||||
|
||||
it("should toggle end arrowhead between default and none", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(getArrow().endArrowhead).toBe(null);
|
||||
|
||||
mouse.doubleClickAt(p2[0], p2[1]);
|
||||
expect(getArrow().endArrowhead).toBe("arrow");
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
|
||||
mouse.doubleClickAt(p2[0], p2[1]);
|
||||
expect(getArrow().endArrowhead).toBe(null);
|
||||
});
|
||||
|
||||
it("should toggle start arrowhead between default and none", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(getArrow().startArrowhead).toBe(null);
|
||||
|
||||
mouse.doubleClickAt(p1[0], p1[1]);
|
||||
expect(getArrow().startArrowhead).toBe("arrow");
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
|
||||
mouse.doubleClickAt(p1[0], p1[1]);
|
||||
expect(getArrow().startArrowhead).toBe(null);
|
||||
});
|
||||
|
||||
it("should restore the arrowhead removed by the previous toggle", () => {
|
||||
it.each([
|
||||
["start", null],
|
||||
["start", "triangle"],
|
||||
["end", null],
|
||||
["end", "triangle"],
|
||||
] as const)(
|
||||
"should not toggle the %s arrowhead from %s on endpoint dblclick",
|
||||
async (side, arrowhead) => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
@@ -372,26 +441,25 @@ describe("Test Linear Elements", () => {
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], p2[1] - p1[1])],
|
||||
endArrowhead: "triangle",
|
||||
startArrowhead: side === "start" ? arrowhead : null,
|
||||
endArrowhead: side === "end" ? arrowhead : null,
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
|
||||
mouse.doubleClickAt(p2[0], p2[1]);
|
||||
expect(getArrow().endArrowhead).toBe(null);
|
||||
const endpoint = side === "start" ? p1 : p2;
|
||||
mouse.doubleClickAt(endpoint[0], endpoint[1]);
|
||||
|
||||
mouse.doubleClickAt(p2[0], p2[1]);
|
||||
expect(getArrow().endArrowhead).toBe("triangle");
|
||||
});
|
||||
const updatedArrow = h.elements[0] as ExcalidrawArrowElement;
|
||||
expect(
|
||||
side === "start"
|
||||
? updatedArrow.startArrowhead
|
||||
: updatedArrow.endArrowhead,
|
||||
).toBe(arrowhead);
|
||||
|
||||
it("should not toggle arrowheads on dblclick outside endpoints", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
|
||||
mouse.doubleClickAt(midpoint[0], midpoint[1]);
|
||||
expect(getArrow().startArrowhead).toBe(null);
|
||||
expect(getArrow().endArrowhead).toBe(null);
|
||||
});
|
||||
});
|
||||
await getTextEditor();
|
||||
},
|
||||
);
|
||||
|
||||
it("shouldn't create text element on double click in line editor (arrow)", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
@@ -438,7 +506,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -479,7 +547,7 @@ describe("Test Linear Elements", () => {
|
||||
fireEvent.click(screen.getByTitle("Round"));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`10`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -540,7 +608,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`13`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -608,7 +676,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`15`,
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
|
||||
@@ -659,7 +727,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -700,7 +768,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -748,7 +816,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
`22`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
|
||||
|
||||
@@ -806,7 +874,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`15`,
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -904,7 +972,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -1138,6 +1206,93 @@ describe("Test Linear Elements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getPointAtPathParameter", () => {
|
||||
it("should survive midpoint insertion", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 0)],
|
||||
roundness: null,
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
|
||||
const before = LinearElementEditor.getPointAtPathParameter(
|
||||
arrow,
|
||||
0.25,
|
||||
arrayToMap(h.elements),
|
||||
)!;
|
||||
expect(before[0]).toBeCloseTo(25);
|
||||
expect(before[1]).toBeCloseTo(0);
|
||||
|
||||
// inserting a midpoint re-segments the path but must not move the
|
||||
// point at the same normalized arc-length parameter
|
||||
act(() => {
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(50, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const after = LinearElementEditor.getPointAtPathParameter(
|
||||
arrow,
|
||||
0.25,
|
||||
arrayToMap(h.elements),
|
||||
)!;
|
||||
expect(after[0]).toBeCloseTo(before[0]);
|
||||
expect(after[1]).toBeCloseTo(before[1]);
|
||||
});
|
||||
|
||||
it("should use the unrounded logical path of elbow arrows", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
elbowed: true,
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
|
||||
// Until mixed paths are supported, elbow arrows are treated as the
|
||||
// straight segments between their logical points.
|
||||
const segments = getLinearElementPathSegments(arrow, elementsMap);
|
||||
expect(segments).toHaveLength(2);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const prevEnd = segments[i - 1][segments[i - 1].length - 1];
|
||||
expect(segments[i][0][0]).toBeCloseTo(prevEnd[0]);
|
||||
expect(segments[i][0][1]).toBeCloseTo(prevEnd[1]);
|
||||
}
|
||||
|
||||
const expectedPoints = [
|
||||
pointFrom<GlobalPoint>(0, 0),
|
||||
pointFrom<GlobalPoint>(50, 0),
|
||||
pointFrom<GlobalPoint>(100, 0),
|
||||
pointFrom<GlobalPoint>(100, 50),
|
||||
pointFrom<GlobalPoint>(100, 100),
|
||||
];
|
||||
for (const [index, parameter] of [0, 0.25, 0.5, 0.75, 1].entries()) {
|
||||
const point = LinearElementEditor.getPointAtPathParameter(
|
||||
arrow,
|
||||
parameter,
|
||||
elementsMap,
|
||||
);
|
||||
expect(point).toEqual(expectedPoints[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should match styles for text editor", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
@@ -1261,7 +1416,7 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"height": 130,
|
||||
"height": 150,
|
||||
"width": "366.11716",
|
||||
}
|
||||
`);
|
||||
@@ -1290,11 +1445,11 @@ describe("Test Linear Elements", () => {
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
20,
|
||||
35,
|
||||
45,
|
||||
"501.11716",
|
||||
95,
|
||||
220,
|
||||
"205.45894",
|
||||
"52.50000",
|
||||
145,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1405,6 +1560,7 @@ describe("Test Linear Elements", () => {
|
||||
"nw",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapText(
|
||||
@@ -1513,6 +1669,270 @@ describe("Test Linear Elements", () => {
|
||||
expect(label.x).toBe(0);
|
||||
expect(label.y).toBe(0);
|
||||
});
|
||||
|
||||
describe("dragging the label along the arrow", () => {
|
||||
// a tall label, so its lower half is beyond the arrow's hit threshold:
|
||||
// there the pointer is over the label but not over the arrow's path
|
||||
const createArrowWithTallLabel = () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
|
||||
});
|
||||
const label = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 60,
|
||||
}),
|
||||
labelPosition: 0.5,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
|
||||
return {
|
||||
label,
|
||||
arrow: {
|
||||
...arrow,
|
||||
boundElements: [{ type: "text", id: label.id } as const],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// covers the label and the arrow's segment midpoint handle, but not the
|
||||
// arrow's start point, so the arrow can still be selected by clicking it
|
||||
const createOccluder = () =>
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
x: 30,
|
||||
y: 5,
|
||||
width: 30,
|
||||
height: 50,
|
||||
backgroundColor: "#ff0000",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
// inside the label, off the arrow's path
|
||||
const labelOnlyPoint = pointFrom<GlobalPoint>(40, 45);
|
||||
|
||||
it("treats a point handle under the label as the handle, on hover and on pointer down", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
|
||||
});
|
||||
const label = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 30,
|
||||
}),
|
||||
// centered on the end point
|
||||
labelPosition: 1,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
API.setElements([
|
||||
{
|
||||
...arrow,
|
||||
boundElements: [{ type: "text", id: label.id } as const],
|
||||
},
|
||||
label,
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
// inside the label and within the end point's handle radius, but
|
||||
// farther from the arrow's path than the arrow's own hit threshold
|
||||
const point = pointFrom<GlobalPoint>(p2[0], p2[1] + 9);
|
||||
|
||||
mouse.moveTo(point[0], point[1]);
|
||||
expect(h.state.selectedLinearElement?.hoverPointIndex).toBe(1);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.POINTER,
|
||||
);
|
||||
|
||||
drag(point, pointFrom<GlobalPoint>(point[0] + 40, point[1]));
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(1);
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawLinearElement).points[1][0],
|
||||
).toBeCloseTo(p2[0] - p1[0] + 40);
|
||||
});
|
||||
|
||||
it("keeps the arrow selected when a point handle is grabbed beyond the arrow's tip", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
// past the end point along the arrow: inside the handle radius, but
|
||||
// outside the arrow's own hit area
|
||||
const point = pointFrom<GlobalPoint>(p2[0] + 9, p2[1]);
|
||||
|
||||
mouse.moveTo(point[0], point[1]);
|
||||
expect(h.state.selectedLinearElement?.hoverPointIndex).toBe(1);
|
||||
|
||||
drag(point, pointFrom<GlobalPoint>(point[0] + 40, point[1]));
|
||||
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.state.selectedElementIds[arrow.id]).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawLinearElement).points[1][0],
|
||||
).toBeCloseTo(p2[0] - p1[0] + 40);
|
||||
});
|
||||
|
||||
it("grabs the label when the covering element is below the arrow", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([createOccluder(), arrow, label]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
drag(labelOnlyPoint, pointFrom<GlobalPoint>(50, 45));
|
||||
|
||||
expect(
|
||||
(h.elements[2] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
it("does not grab the label when it is covered by an element above the arrow", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([arrow, label, createOccluder()]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
drag(labelOnlyPoint, pointFrom<GlobalPoint>(50, 45));
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it("grabs an elbow arrow's label lying over an intermediate route point", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
elbowed: true,
|
||||
});
|
||||
const label = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 30,
|
||||
}),
|
||||
// the arc-length middle of this symmetric elbow sits on the
|
||||
// rounded corner, right next to the route point at (100, 0)
|
||||
labelPosition: 0.5,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
API.setElements([
|
||||
{
|
||||
...arrow,
|
||||
boundElements: [{ type: "text", id: label.id } as const],
|
||||
},
|
||||
label,
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(0, 0);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
// inside the label AND within the hit radius of the route point at
|
||||
// (100, 0) — pre-fix the route point swallowed this pointerdown and
|
||||
// the label never moved
|
||||
const grabPoint = pointFrom<GlobalPoint>(97, 3);
|
||||
|
||||
drag(
|
||||
grabPoint,
|
||||
pointFrom<GlobalPoint>(grabPoint[0] - 40, grabPoint[1]),
|
||||
);
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).not.toBe(0.5);
|
||||
});
|
||||
|
||||
it("gives the segment midpoint handle precedence over the label on top of it", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([arrow, label]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
2,
|
||||
);
|
||||
|
||||
// the label sits centered on the midpoint knob; dragging there must
|
||||
// bend the arrow, not slide the label
|
||||
drag(midpoint, pointFrom<GlobalPoint>(midpoint[0], midpoint[1] + 40));
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it("leaves the segment midpoint handle grabbable under a covered label", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([arrow, label, createOccluder()]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
2,
|
||||
);
|
||||
|
||||
// the midpoint handle sits under the label, which is itself covered
|
||||
drag(midpoint, pointFrom<GlobalPoint>(midpoint[0], midpoint[1] + 40));
|
||||
|
||||
expect(
|
||||
h.state.selectedLinearElement?.initialState.segmentMidpoint.value,
|
||||
).not.toBeNull();
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test moving linear element points", () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as common from "@excalidraw/common";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { newElement, newLinearElement } from "../src/newElement";
|
||||
import { mutateElement, newElementWith } from "../src/mutateElement";
|
||||
|
||||
describe("element creation time", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rectangle",
|
||||
"diamond",
|
||||
"ellipse",
|
||||
"text",
|
||||
"line",
|
||||
"arrow",
|
||||
"freedraw",
|
||||
"image",
|
||||
"frame",
|
||||
"magicframe",
|
||||
"embeddable",
|
||||
"iframe",
|
||||
] as const)("initializes a new %s before any capture", (type) => {
|
||||
vi.spyOn(common, "getUpdatedTimestamp").mockReturnValue(123);
|
||||
const element = API.createElement({ type });
|
||||
expect(element.created).toBe(123);
|
||||
|
||||
vi.mocked(common.getUpdatedTimestamp).mockReturnValue(456);
|
||||
const edited = newElementWith(element, { x: 50 });
|
||||
mutateElement(edited, new Map(), { y: 50 });
|
||||
|
||||
expect(edited).toMatchObject({ created: 123, updated: 456 });
|
||||
});
|
||||
|
||||
it.each([123, 0, null])(
|
||||
"preserves explicit created=%s when reconstructing the same element",
|
||||
(created) => {
|
||||
const original = newElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
created,
|
||||
});
|
||||
const converted = newLinearElement({ ...original, type: "line" });
|
||||
|
||||
expect(converted).toMatchObject({ id: original.id, created });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import { recognizeShape } from "../src/convertToShape";
|
||||
|
||||
// Deterministic jitter, so a run that passes keeps passing.
|
||||
let seed = 1;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648 - 0.5;
|
||||
};
|
||||
|
||||
// Draw the shape somewhere else, at another size, turned by `angle`, with a
|
||||
// wobbly hand.
|
||||
const sketch = (
|
||||
points: readonly [number, number][],
|
||||
{ angle = 0, scale = 1, noise = 0 } = {},
|
||||
): GlobalPoint[] =>
|
||||
points.map(([x, y]) => {
|
||||
const nx = x * scale + random() * noise;
|
||||
const ny = y * scale + random() * noise;
|
||||
return [
|
||||
nx * Math.cos(angle) - ny * Math.sin(angle) + 600,
|
||||
nx * Math.sin(angle) + ny * Math.cos(angle) + 600,
|
||||
] as GlobalPoint;
|
||||
});
|
||||
|
||||
const edge = (
|
||||
from: readonly [number, number],
|
||||
to: readonly [number, number],
|
||||
steps: number,
|
||||
): [number, number][] =>
|
||||
Array.from({ length: steps }, (_, i) => [
|
||||
from[0] + ((to[0] - from[0]) * i) / steps,
|
||||
from[1] + ((to[1] - from[1]) * i) / steps,
|
||||
]);
|
||||
|
||||
const outline = (corners: readonly [number, number][]): [number, number][] => [
|
||||
...corners.flatMap((corner, i) => edge(corner, corners[(i + 1) % 4], 18)),
|
||||
corners[0],
|
||||
];
|
||||
|
||||
const rectangle = (w: number, h: number) =>
|
||||
outline([
|
||||
[-w / 2, -h / 2],
|
||||
[w / 2, -h / 2],
|
||||
[w / 2, h / 2],
|
||||
[-w / 2, h / 2],
|
||||
]);
|
||||
|
||||
const diamond = (w: number, h: number) =>
|
||||
outline([
|
||||
[0, -h / 2],
|
||||
[w / 2, 0],
|
||||
[0, h / 2],
|
||||
[-w / 2, 0],
|
||||
]);
|
||||
|
||||
const ellipse = (rx: number, ry: number): [number, number][] => {
|
||||
const points: [number, number][] = [];
|
||||
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
|
||||
points.push([Math.cos(a) * rx, Math.sin(a) * ry]);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
// Moving-average smoothing: rounds every corner the way a relaxed pen does.
|
||||
const smooth = (
|
||||
pts: readonly [number, number][],
|
||||
window: number,
|
||||
): [number, number][] =>
|
||||
pts.map((_, i) => {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (let k = -window; k <= window; k++) {
|
||||
const [x, y] = pts[(i + k + pts.length) % pts.length];
|
||||
sx += x;
|
||||
sy += y;
|
||||
}
|
||||
return [sx / (2 * window + 1), sy / (2 * window + 1)];
|
||||
});
|
||||
|
||||
// A sloppy hand-drawn "rectangle" that tapers, bucket-like: wider at the top
|
||||
// than the bottom. Its hull fill ratio, (topW + bottomW) / (2 * topW), lands
|
||||
// right at an ellipse's PI/4 — only its sharp corners tell the two apart.
|
||||
const taperedRectangle = (
|
||||
topW: number,
|
||||
bottomW: number,
|
||||
h: number,
|
||||
): [number, number][] =>
|
||||
outline([
|
||||
[-topW / 2, -h / 2],
|
||||
[topW / 2, -h / 2],
|
||||
[bottomW / 2, h / 2],
|
||||
[-bottomW / 2, h / 2],
|
||||
]);
|
||||
|
||||
// A rectangle drawn with broad, pen-like rounded corners. Its outline is a
|
||||
// superellipse midway between an ellipse (exponent 2) and a sharp rectangle.
|
||||
const roundedRectangle = (rx: number, ry: number): [number, number][] => {
|
||||
const points: [number, number][] = [];
|
||||
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
|
||||
const cos = Math.cos(a);
|
||||
const sin = Math.sin(a);
|
||||
points.push([
|
||||
Math.sign(cos) * Math.sqrt(Math.abs(cos)) * rx,
|
||||
Math.sign(sin) * Math.sqrt(Math.abs(sin)) * ry,
|
||||
]);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
const line = (length: number) => edge([-length / 2, 0], [length / 2, 0], 40);
|
||||
|
||||
/** A shaft, then a V head traced back from the tip and over itself. */
|
||||
const arrow = (length: number, headRatio: number): [number, number][] => {
|
||||
const tip: [number, number] = [length / 2, 0];
|
||||
const headLength = length * headRatio;
|
||||
const arm = (side: number): [number, number] => [
|
||||
tip[0] - headLength * Math.cos(Math.PI / 6),
|
||||
tip[1] - side * headLength * Math.sin(Math.PI / 6),
|
||||
];
|
||||
return [
|
||||
...edge([-length / 2, 0], tip, 24),
|
||||
...edge(tip, arm(1), 8),
|
||||
...edge(arm(1), tip, 8),
|
||||
...edge(tip, arm(-1), 8),
|
||||
];
|
||||
};
|
||||
|
||||
const SCALES = [0.5, 1, 2, 4];
|
||||
|
||||
describe("recognizeShape", () => {
|
||||
describe("is invariant to where, how big and how rotated a shape is drawn", () => {
|
||||
// A rectangle turned 45° *is* a diamond, so these two are only swept over
|
||||
// the right angles that map them onto themselves. Every other class is
|
||||
// swept over arbitrary angles.
|
||||
const RIGHT_ANGLES = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
|
||||
// 4.7 rad is within a degree of vertical — the size gate compares the
|
||||
// larger bounding-box dimension, so thin (near-)axis-aligned strokes are
|
||||
// recognized like any other.
|
||||
const ANY_ANGLES = [0, 0.3, Math.PI / 4, 1.2, Math.PI / 2, 2.5, 4.7];
|
||||
|
||||
const cases: [string, [number, number][], number[], string][] = [
|
||||
["a square", rectangle(200, 200), RIGHT_ANGLES, "rectangle"],
|
||||
["a wide rectangle", rectangle(300, 150), RIGHT_ANGLES, "rectangle"],
|
||||
["a thin rectangle", rectangle(500, 100), RIGHT_ANGLES, "rectangle"],
|
||||
[
|
||||
"a tapered rectangle",
|
||||
taperedRectangle(170, 100, 200),
|
||||
RIGHT_ANGLES,
|
||||
"rectangle",
|
||||
],
|
||||
["a diamond", diamond(200, 200), RIGHT_ANGLES, "diamond"],
|
||||
["a wide diamond", diamond(300, 150), RIGHT_ANGLES, "diamond"],
|
||||
["a circle", ellipse(100, 100), ANY_ANGLES, "ellipse"],
|
||||
["an ellipse", ellipse(150, 75), ANY_ANGLES, "ellipse"],
|
||||
["a line", line(300), ANY_ANGLES, "line"],
|
||||
["an arrow with a small head", arrow(300, 0.15), ANY_ANGLES, "arrow"],
|
||||
["an arrow with a large head", arrow(300, 0.45), ANY_ANGLES, "arrow"],
|
||||
];
|
||||
|
||||
it.each(cases)("recognizes %s", (_name, points, angles, expected) => {
|
||||
for (const angle of angles) {
|
||||
for (const scale of SCALES) {
|
||||
for (const noise of [0, 3]) {
|
||||
expect({
|
||||
angle,
|
||||
scale,
|
||||
noise,
|
||||
type: recognizeShape(
|
||||
sketch(points, { angle, scale, noise }),
|
||||
null,
|
||||
).type,
|
||||
}).toEqual({ angle, scale, noise, type: expected });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("tells an arrow from a line by the points its head piles up", () => {
|
||||
const options = { angle: 0.3 };
|
||||
expect(recognizeShape(sketch(line(300), options), null).type).toBe("line");
|
||||
expect(recognizeShape(sketch(arrow(300, 0.3), options), null).type).toBe(
|
||||
"arrow",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes an outline the user didn't quite close", () => {
|
||||
const gapped = rectangle(200, 200).slice(0, -6);
|
||||
|
||||
expect(recognizeShape(sketch(gapped), null).type).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("recognizes a pen-drawn outline with rounded corners as a rectangle", () => {
|
||||
expect(recognizeShape(sketch(roundedRectangle(100, 70)), null).type).toBe(
|
||||
"rectangle",
|
||||
);
|
||||
|
||||
// ...while a true ellipse of the same proportions remains an ellipse
|
||||
expect(recognizeShape(sketch(ellipse(100, 70)), null).type).toBe("ellipse");
|
||||
});
|
||||
|
||||
it("tells a tapered, bucket-like rectangle from an ellipse by its corners", () => {
|
||||
// proportions traced from real sketches that used to recognize as
|
||||
// ellipses: hull fill ≈ 0.8 ≈ PI/4, so corner sharpness must decide
|
||||
const buckets: [number, number, number][] = [
|
||||
[110, 65, 220],
|
||||
[110, 65, 130],
|
||||
[75, 50, 115],
|
||||
[120, 60, 250],
|
||||
];
|
||||
|
||||
for (const [topW, bottomW, h] of buckets) {
|
||||
// lightly rounded corners + wobble, like the real strokes
|
||||
const points = sketch(smooth(taperedRectangle(topW, bottomW, h), 2), {
|
||||
noise: 2,
|
||||
});
|
||||
|
||||
expect({
|
||||
bucket: [topW, bottomW, h],
|
||||
type: recognizeShape(points, null).type,
|
||||
}).toEqual({ bucket: [topW, bottomW, h], type: "rectangle" });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not read sharply bent open strokes as lines or arrows", () => {
|
||||
const semicircle: [number, number][] = Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => {
|
||||
const a = (Math.PI * i) / 39;
|
||||
return [Math.cos(a) * 150, Math.sin(a) * 150];
|
||||
},
|
||||
);
|
||||
const sawtooth: [number, number][] = Array.from({ length: 31 }, (_, i) => [
|
||||
i * 10,
|
||||
(i % 2) * 60,
|
||||
]);
|
||||
|
||||
const bent: [string, [number, number][]][] = [
|
||||
[
|
||||
"a right-angle elbow",
|
||||
[...edge([0, 0], [110, 110], 20), ...edge([110, 110], [220, 0], 20)],
|
||||
],
|
||||
[
|
||||
"an L shape",
|
||||
[...edge([0, 0], [0, 150], 20), ...edge([0, 150], [150, 150], 20)],
|
||||
],
|
||||
[
|
||||
"a checkmark",
|
||||
[...edge([0, 60], [60, 120], 12), ...edge([60, 120], [200, 0], 24)],
|
||||
],
|
||||
["a semicircular arc", semicircle],
|
||||
["a sawtooth", sawtooth],
|
||||
];
|
||||
|
||||
for (const [name, points] of bent) {
|
||||
expect({
|
||||
name,
|
||||
type: recognizeShape(sketch(points, { noise: 2 }), null).type,
|
||||
}).toEqual({ name, type: "freedraw" });
|
||||
}
|
||||
});
|
||||
|
||||
it("still reads a lazily bowed or squiggly stroke as a line", () => {
|
||||
const bowed: [number, number][] = Array.from({ length: 40 }, (_, i) => {
|
||||
const t = i / 39;
|
||||
return [t * 300, Math.sin(t * Math.PI) * 24];
|
||||
});
|
||||
const squiggly: [number, number][] = Array.from({ length: 40 }, (_, i) => {
|
||||
const t = i / 39;
|
||||
return [t * 300, Math.sin(t * Math.PI * 4) * 12];
|
||||
});
|
||||
|
||||
expect(recognizeShape(sketch(bowed, { noise: 2 }), null).type).toBe("line");
|
||||
expect(recognizeShape(sketch(squiggly, { noise: 2 }), null).type).toBe(
|
||||
"line",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a stroke that resembles no known shape as freedraw", () => {
|
||||
const squiggle: [number, number][] = Array.from({ length: 60 }, (_, i) => [
|
||||
i * 4 - 120,
|
||||
Math.sin(i / 2) * 70 + random() * 20,
|
||||
]);
|
||||
|
||||
expect(recognizeShape(sketch(squiggle), null).type).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("only extends an arrow with another arrow", () => {
|
||||
const previous = { type: "arrow" } as Parameters<typeof recognizeShape>[1];
|
||||
|
||||
expect(
|
||||
recognizeShape(sketch(arrow(300, 0.3), { angle: 0.3 }), previous).type,
|
||||
).toBe("arrow");
|
||||
expect(recognizeShape(sketch(rectangle(200, 200)), previous).type).toBe(
|
||||
"freedraw",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a stroke too small to be a shape", () => {
|
||||
const tiny: [number, number][] = [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[2, 0],
|
||||
[1, -1],
|
||||
];
|
||||
|
||||
expect(recognizeShape(sketch(tiny), null).type).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("gates on apparent (screen) size, so zoom decides what is too small", () => {
|
||||
const smallCircle = ellipse(8, 8); // 16 scene units across
|
||||
|
||||
expect(recognizeShape(sketch(smallCircle), null).type).toBe("freedraw");
|
||||
expect(recognizeShape(sketch(smallCircle), null, 10).type).toBe("ellipse");
|
||||
|
||||
// 300 apparent px at 0.1 zoom
|
||||
expect(
|
||||
recognizeShape(sketch(line(3000), { angle: 0.3 }), null, 0.1).type,
|
||||
).toBe("line");
|
||||
// 10 apparent px at 0.1 zoom
|
||||
expect(
|
||||
recognizeShape(sketch(line(100), { angle: 0.3 }), null, 0.1).type,
|
||||
).toBe("freedraw");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
bezierEquation,
|
||||
isCurve,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
@@ -12,14 +17,19 @@ import {
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isLinearElement } from "../src/typeChecks";
|
||||
import { resizeSingleElement } from "../src/resizeElements";
|
||||
import { distanceToElement } from "../src/distance";
|
||||
import { getLinearElementPathSegments } from "../src/utils";
|
||||
import { LinearElementEditor } from "../src/linearElementEditor";
|
||||
import { getElementPointsCoords } from "../src/bounds";
|
||||
import { computeContainerDimensionForBoundText } from "../src/textElement";
|
||||
@@ -229,6 +239,45 @@ describe("generic element", () => {
|
||||
expect(label.fontSize).toEqual(20);
|
||||
});
|
||||
|
||||
describe.each(["rectangle", "ellipse", "diamond"] as const)(
|
||||
"%s with a label",
|
||||
(type) => {
|
||||
it.each([false, true])(
|
||||
"flips horizontally while resizing (proportional: %s)",
|
||||
async (proportional) => {
|
||||
const container = UI.createElement(type, {
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
const label = await UI.editText(container, "Hello");
|
||||
|
||||
UI.resize(container, proportional ? "se" : "e", [-500, 0], {
|
||||
shift: proportional,
|
||||
});
|
||||
|
||||
expect(container.x).toBeCloseTo(-300);
|
||||
expect(container.y).toBeCloseTo(0);
|
||||
expect(container.width).toBeCloseTo(300);
|
||||
expect(container.height).toBeCloseTo(proportional ? 150 : 100);
|
||||
expect(label.x + label.width / 2).toBeCloseTo(
|
||||
container.x + container.width / 2,
|
||||
0,
|
||||
);
|
||||
expect(label.y + label.height / 2).toBeCloseTo(
|
||||
container.y + container.height / 2,
|
||||
0,
|
||||
);
|
||||
expect(label.angle).toBe(0);
|
||||
if (proportional) {
|
||||
expect(label.fontSize).toBeGreaterThan(20);
|
||||
} else {
|
||||
expect(label.fontSize).toBe(20);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<{ handle: TransformHandleDirection; move: [number, number] }>([
|
||||
{ handle: "n", move: [0, 100] },
|
||||
{ handle: "s", move: [0, -100] },
|
||||
@@ -258,6 +307,47 @@ describe("generic element", () => {
|
||||
expect(rectangle.height).toBeCloseTo(minContainerHeight);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the flipped corner anchored while wrapped text sets the minimum height", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
const label = await UI.editText(rectangle, "first second third fourth");
|
||||
const originalElements = arrayToMap(
|
||||
h.app.scene.getNonDeletedElements().map((element) => ({ ...element })),
|
||||
);
|
||||
const originalRectangle = originalElements.get(rectangle.id)!;
|
||||
let minHeight = 0;
|
||||
|
||||
// One SE drag crosses the top edge, stays below the wrapped text's
|
||||
// minimum height, then grows far enough for the pointer to control it.
|
||||
for (const nextHeight of [-10, -40, -80, -200]) {
|
||||
act(() => {
|
||||
resizeSingleElement(
|
||||
80,
|
||||
nextHeight,
|
||||
h.app.scene.getNonDeletedElement(rectangle.id)!,
|
||||
originalRectangle,
|
||||
originalElements,
|
||||
h.app.scene,
|
||||
"se",
|
||||
);
|
||||
});
|
||||
|
||||
expect(rectangle.x).toBe(originalRectangle.x);
|
||||
expect(rectangle.y + rectangle.height).toBe(originalRectangle.y);
|
||||
expect(label.y).toBeGreaterThan(rectangle.y);
|
||||
expect(label.y + label.height).toBeLessThan(
|
||||
rectangle.y + rectangle.height,
|
||||
);
|
||||
if (nextHeight === -10) {
|
||||
minHeight = rectangle.height;
|
||||
expect(minHeight).toBeGreaterThan(80);
|
||||
}
|
||||
expect(rectangle.height).toBe(Math.max(minHeight, -nextHeight));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
@@ -490,37 +580,86 @@ describe("arrow element", () => {
|
||||
});
|
||||
const label = await UI.editText(arrow, "Hello");
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
UI.resize(arrow, "se", [50, 30]);
|
||||
let labelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
arrow,
|
||||
label,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(labelPos.x + label.width / 2).toBeCloseTo(
|
||||
arrow.x + arrow.points[2][0],
|
||||
);
|
||||
expect(labelPos.y + label.height / 2).toBeCloseTo(
|
||||
arrow.y + arrow.points[2][1],
|
||||
);
|
||||
// independently locate the arc-length midpoint by densely sampling the
|
||||
// path, so a regression in the arc-length inversion the production code
|
||||
// uses cannot cancel out of the comparison
|
||||
const sampledPathMidpoint = (): GlobalPoint => {
|
||||
const samples: GlobalPoint[] = [];
|
||||
for (const segment of getLinearElementPathSegments(arrow, elementsMap)) {
|
||||
for (let i = 0; i <= 1000; i++) {
|
||||
const t = i / 1000;
|
||||
samples.push(
|
||||
isCurve(segment)
|
||||
? bezierEquation(segment, t)
|
||||
: pointFrom<GlobalPoint>(
|
||||
segment[0][0] + t * (segment[1][0] - segment[0][0]),
|
||||
segment[0][1] + t * (segment[1][1] - segment[0][1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
cumulative.push(
|
||||
cumulative[i - 1] + pointDistance(samples[i - 1], samples[i]),
|
||||
);
|
||||
}
|
||||
const half = cumulative[cumulative.length - 1] / 2;
|
||||
return samples[cumulative.findIndex((length) => length >= half)];
|
||||
};
|
||||
|
||||
// the label defaults to the arc-length midpoint of the arrow's path and
|
||||
// keeps that normalized position through resizes
|
||||
const expectLabelAtPathMidpoint = () => {
|
||||
const labelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
arrow,
|
||||
label,
|
||||
elementsMap,
|
||||
);
|
||||
const centerX = labelPos.x + label.width / 2;
|
||||
const centerY = labelPos.y + label.height / 2;
|
||||
const pathMidpoint = sampledPathMidpoint();
|
||||
|
||||
expect(label.labelPosition).toBe(0.5);
|
||||
expect(centerX).toBeCloseTo(pathMidpoint[0], 0);
|
||||
expect(centerY).toBeCloseTo(pathMidpoint[1], 0);
|
||||
expect(
|
||||
distanceToElement(arrow, elementsMap, pointFrom(centerX, centerY)),
|
||||
).toBeLessThan(0.5);
|
||||
};
|
||||
|
||||
expectLabelAtPathMidpoint();
|
||||
|
||||
UI.resize(arrow, "se", [50, 30]);
|
||||
expectLabelAtPathMidpoint();
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expect(label.fontSize).toEqual(20);
|
||||
|
||||
UI.resize(arrow, "w", [20, 0]);
|
||||
labelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
arrow,
|
||||
label,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(labelPos.x + label.width / 2).toBeCloseTo(
|
||||
arrow.x + arrow.points[2][0],
|
||||
);
|
||||
expect(labelPos.y + label.height / 2).toBeCloseTo(
|
||||
arrow.y + arrow.points[2][1],
|
||||
);
|
||||
expectLabelAtPathMidpoint();
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expect(label.fontSize).toEqual(20);
|
||||
|
||||
const { x, width, height } = arrow;
|
||||
const latestArrow = h.app.scene.getNonDeletedElement(arrow.id)!;
|
||||
act(() => {
|
||||
resizeSingleElement(
|
||||
-2 * width,
|
||||
2 * height,
|
||||
latestArrow,
|
||||
{ ...latestArrow },
|
||||
arrayToMap(h.elements.map((element) => ({ ...element }))),
|
||||
h.app.scene,
|
||||
"se",
|
||||
{ shouldMaintainAspectRatio: true },
|
||||
);
|
||||
});
|
||||
expect(getBoundsFromPoints(arrow)[2]).toBeCloseTo(x);
|
||||
expect(arrow.width).toBeCloseTo(2 * width);
|
||||
expect(label.fontSize).toBeCloseTo(40);
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expectLabelAtPathMidpoint();
|
||||
});
|
||||
|
||||
it("flips the fixed point binding on negative resize for single bindable", () => {
|
||||
@@ -866,6 +1005,30 @@ describe("image element", () => {
|
||||
});
|
||||
|
||||
describe("multiple selection", () => {
|
||||
it("keeps a flipped text container anchored when its label requires extra height", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
position: 0,
|
||||
width: 200,
|
||||
height: 35,
|
||||
});
|
||||
const label = await UI.editText(rectangle, "hello");
|
||||
const other = UI.createElement("rectangle", {
|
||||
x: 300,
|
||||
y: 0,
|
||||
size: 100,
|
||||
});
|
||||
const originalHeight = rectangle.height;
|
||||
|
||||
// Halve the selection and flip vertically. Padding does not scale with
|
||||
// the font, so even a single-line label needs extra container height.
|
||||
UI.resize([rectangle, other], "se", [-200, -150], { shift: true });
|
||||
|
||||
expect(label.fontSize).toBeCloseTo(10);
|
||||
expect(label.text).toBe("hello");
|
||||
expect(rectangle.height).toBeGreaterThan(originalHeight / 2);
|
||||
expect(rectangle.y + rectangle.height).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("resizes with generic elements", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
position: 0,
|
||||
|
||||
@@ -13,6 +13,22 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Wheel navigation (2026-09-13) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
|
||||
- Scrolling while holding the wheel (middle) mouse button zooms the canvas around the pointer, the same as ctrl/cmd+wheel — a wheel-button pan can be zoomed one-handed, without reaching for a modifier.
|
||||
- New `appState.inputDevice` preference (`"auto" | "mouse" | "trackpad"`, default `"auto"`, persisted in browser storage) selecting the wheel mappings. With `"trackpad"` a plain wheel pans and ctrl/cmd+wheel (how a pinch is delivered) zooms, as before; with `"mouse"` a plain wheel zooms and ctrl/cmd+wheel pans vertically instead, while shift+wheel keeps panning horizontally and the wheel-button zoom above is unaffected. `"auto"` is reserved for detecting the device from the wheel events and resolves to `"trackpad"` until that is implemented (`resolveInputDevice()`). Set it from the new "Input device" radio in the main menu's preferences submenu, also exposed as `MainMenu.DefaultItems.Preferences.InputDevice`.
|
||||
|
||||
### Sticky notes (2026-09-06) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
|
||||
- New `stickynote` element type and toolbar tool (`N`): an always-filled, flat-rendered note whose label auto-fits — the font shrinks from the user's size down to a minimum, and only then does the note grow in height (it never shrinks below the height the user gave it). Click to place a 250×250 note, drag to size it (previewed at the dragged size, snapped to a font-aware minimum on release); text editing starts right away.
|
||||
- Element model: the note carries `baseHeight` (the user's height; `height ≥ baseHeight`), its label carries `baseFontSize` (the user's font ceiling, `null`/absent on any other text) while `fontSize` holds the fitted size. Read a text element's user-facing size through `getBaseFontSize(text, elementsMap)`.
|
||||
- Sticky notes are their own color domain: new `appState.currentItemStickynoteStrokeColor` (the note's text color) and `appState.currentItemStickynoteBackgroundColor` defaults, and new customizable top-pick slots `appState.colorTopPicks.stickyNoteStroke` / `stickyNoteBackground`. The stroke and background pickers, both eyedroppers and paste-styles resolve their target (regular / sticky / mixed) at execution time. A note and its label share one ink (the stroke picker reads "Text color" for notes): coloring the label while editing colors the note too, binding a colored text gives the note its color, and the creation-date footer always paints in that ink.
|
||||
- Skeleton API: `convertToExcalidrawElements([{ type: "stickynote", x, y, label?: { text, fontSize? } }])` creates a note (and fitted label); see the element-skeleton docs.
|
||||
- Tools can be dragged out of the toolbar: drag the sticky note button onto the canvas to drop a default-sized note where you release it, previewed translucently while dragging (nothing reaches the scene, history or collaborators until the drop; Escape cancels). The mechanism is generic — other tools can opt in.
|
||||
- Toolbar: on desktop and tablet the image tool moved into the "More tools" menu (still `9`; `UIOptions.tools.image` keeps hiding it; the mobile toolbar keeps its image slot), and tool badges now show the letter shortcuts (`R`, `T`, `N`, …) instead of the numbers, which are no longer contiguous. The number keys keep working.
|
||||
- Each note shows its creation date (`element.created`) in a footer band — "7 Sep", or "7 Sep 2025" once the year differs — on canvas and in SVG/PNG exports. The band is reserved in the note's layout, so the label never overlaps it; notes without a timestamp keep the band empty.
|
||||
- Resize semantics: a note's corners resize proportionally by default and scale the label's font ceiling with the note (Shift frees them); its edges resize freely by default (Shift constrains them). Drag-creation is proportional by default too (Shift frees it) and never touches the font ceiling. Width-only gestures keep the base height, height gestures set it, aspect-locked multi-select and Stats group edits scale the ceiling; flips preserve everything. Arrows bound to a note follow it as it grows.
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### Host-controlled active tool (2026-07-14) [#11665](https://github.com/excalidraw/excalidraw/pull/11665)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getStrokeWidthByKey,
|
||||
isTransparent,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
@@ -15,15 +16,19 @@ import {
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
normalizeStickyNoteFontSize,
|
||||
redrawTextBoundingBox,
|
||||
updateStickyNoteLayout,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
@@ -86,17 +91,26 @@ export const actionUnbindText = register({
|
||||
width,
|
||||
height,
|
||||
text: boundTextElement.originalText,
|
||||
baseFontSize: null,
|
||||
x,
|
||||
y,
|
||||
labelPosition: null,
|
||||
});
|
||||
app.scene.mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
height: originalContainerHeight
|
||||
? originalContainerHeight
|
||||
: element.height,
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// an empty note sits at its base height; bound arrows follow
|
||||
updateStickyNoteLayout(element, app.scene);
|
||||
} else {
|
||||
app.scene.mutateElement(element, {
|
||||
height: originalContainerHeight
|
||||
? originalContainerHeight
|
||||
: element.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
@@ -154,18 +168,38 @@ export const actionBindText = register({
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
// a note and its label share one ink: the text the user styled wins,
|
||||
// unless it is transparent (a note's label never is)
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? isTransparent(textElement.strokeColor)
|
||||
? container.strokeColor
|
||||
: textElement.strokeColor
|
||||
: null;
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
...(stickyInk
|
||||
? {
|
||||
baseFontSize: normalizeStickyNoteFontSize(
|
||||
textElement.baseFontSize ?? textElement.fontSize,
|
||||
),
|
||||
strokeColor: stickyInk,
|
||||
}
|
||||
: null),
|
||||
});
|
||||
app.scene.mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
// the footer paints with the note's ink
|
||||
...(stickyInk ? { strokeColor: stickyInk } : null),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
|
||||
@@ -111,6 +111,7 @@ export const actionClearCanvas = register({
|
||||
gridStep: appState.gridStep,
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
stats: appState.stats,
|
||||
colorTopPicks: appState.colorTopPicks,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? {
|
||||
@@ -133,6 +134,7 @@ export const actionZoomIn = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (_elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const nextState = {
|
||||
...appState,
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
@@ -143,7 +145,6 @@ export const actionZoomIn = register({
|
||||
},
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
};
|
||||
return {
|
||||
appState: nextState,
|
||||
@@ -180,6 +181,7 @@ export const actionZoomOut = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (_elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const nextState = {
|
||||
...appState,
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
@@ -190,7 +192,6 @@ export const actionZoomOut = register({
|
||||
},
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
};
|
||||
return {
|
||||
appState: nextState,
|
||||
@@ -227,6 +228,7 @@ export const actionResetZoom = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (_elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
// reset to 100%, unless a zoom lock floors the zoom higher — then reset to
|
||||
// the locked minimum zoom (the lock's resting zoom level)
|
||||
const nextZoom = appState.scrollConstraints?.lockZoom
|
||||
@@ -242,7 +244,6 @@ export const actionResetZoom = register({
|
||||
},
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
};
|
||||
return {
|
||||
appState: nextState,
|
||||
@@ -300,6 +301,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const bounds = selectedElements.length
|
||||
? getCommonBounds(getNonDeletedElements(selectedElements))
|
||||
@@ -308,10 +310,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
: getCommonBounds(getNonDeletedElements(elements));
|
||||
const result = zoomToFitBounds({
|
||||
bounds,
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
appState,
|
||||
fit: "scale-down",
|
||||
canvasOffsets: app.viewport.getOffsets(),
|
||||
});
|
||||
@@ -345,6 +344,7 @@ export const actionZoomToFitSelection = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const bounds = selectedElements.length
|
||||
? getCommonBounds(getNonDeletedElements(selectedElements))
|
||||
@@ -353,10 +353,7 @@ export const actionZoomToFitSelection = register({
|
||||
: getCommonBounds(getNonDeletedElements(elements));
|
||||
const result = zoomToFitBounds({
|
||||
bounds,
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
appState,
|
||||
fit: "contain",
|
||||
canvasOffsets: app.viewport.getOffsets(),
|
||||
});
|
||||
@@ -386,16 +383,14 @@ export const actionZoomToFit = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, appProps, app) => app.isNavigationEnabled(),
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.requestUnfollow();
|
||||
// under a viewport lock, fits the locked box rather than the elements
|
||||
const bounds = appState.scrollConstraints
|
||||
? getScrollConstraintsBounds(appState.scrollConstraints)
|
||||
: getCommonBounds(getNonDeletedElements(elements));
|
||||
const result = zoomToFitBounds({
|
||||
bounds,
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
appState,
|
||||
fit: "scale-down",
|
||||
canvasOffsets: app.viewport.getOffsets(),
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ export const actionDeselect = register({
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, _, app) => {
|
||||
const activeTool = getNextActiveTool(appState, app);
|
||||
app.cursor.applyForTool(activeTool);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
@@ -103,6 +104,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -121,6 +123,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { AppState } from "../types";
|
||||
type FormData = {
|
||||
event: PointerEvent;
|
||||
sceneCoords: { x: number; y: number };
|
||||
hitBoundText?: boolean;
|
||||
};
|
||||
|
||||
export const actionFinalize = register<FormData>({
|
||||
@@ -58,6 +59,7 @@ export const actionFinalize = register<FormData>({
|
||||
let newElements = elements;
|
||||
const { focusContainer, scene } = app;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const isDrawShapeTool = appState.activeTool.type === "autoshape";
|
||||
|
||||
if (data && appState.selectedLinearElement) {
|
||||
const { event, sceneCoords } = data;
|
||||
@@ -85,7 +87,10 @@ export const actionFinalize = register<FormData>({
|
||||
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
!appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||
!appState.selectedLinearElement.segmentMidPointHoveredCoords &&
|
||||
// a label drag along the arrow moves no endpoint, so it must not
|
||||
// rebind either of them
|
||||
!data.hitBoundText
|
||||
) {
|
||||
const newArrow = !!appState.newElement;
|
||||
|
||||
@@ -174,20 +179,25 @@ export const actionFinalize = register<FormData>({
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
selectedLinearElement: activeToolLocked
|
||||
? null
|
||||
: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
isEditing: false,
|
||||
initialState: {
|
||||
...linearElementEditor.initialState,
|
||||
lastClickedPoint: -1,
|
||||
selectedElementIds: isDrawShapeTool
|
||||
? {}
|
||||
: appState.selectedElementIds,
|
||||
selectedLinearElement:
|
||||
activeToolLocked || isDrawShapeTool
|
||||
? null
|
||||
: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
isEditing: false,
|
||||
initialState: {
|
||||
...linearElementEditor.initialState,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
},
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
},
|
||||
selectionElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
newElement: null,
|
||||
multiElement: null,
|
||||
},
|
||||
@@ -196,16 +206,31 @@ export const actionFinalize = register<FormData>({
|
||||
}
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
focusContainer();
|
||||
|
||||
// clean up pending gesture even if active tool is already not drawShape
|
||||
const hadPendingSketch = app.drawShape.hasPendingGesture();
|
||||
if (hadPendingSketch || isDrawShapeTool) {
|
||||
app.drawShape.finalize();
|
||||
if (hadPendingSketch) {
|
||||
// finalize() inserts the recognized element via app.insertNewElement
|
||||
// — re-read so the returned array includes it (replaceAllElements
|
||||
// would otherwise drop it)
|
||||
newElements = app.scene.getElementsIncludingDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
let element: NonDeleted<ExcalidrawElement> | null = null;
|
||||
if (appState.multiElement) {
|
||||
element = appState.multiElement;
|
||||
} else if (
|
||||
appState.newElement?.type === "freedraw" ||
|
||||
isBindingElement(appState.newElement)
|
||||
// the drawShape preview in `newElement` is not a scene element and the
|
||||
// sketch was finalized by app.drawShape.finalize() above — never treat
|
||||
// the preview as an in-progress element here
|
||||
!isDrawShapeTool &&
|
||||
!hadPendingSketch &&
|
||||
(appState.newElement?.type === "freedraw" ||
|
||||
isBindingElement(appState.newElement))
|
||||
) {
|
||||
element = appState.newElement;
|
||||
} else if (Object.keys(appState.selectedElementIds).length === 1) {
|
||||
@@ -333,10 +358,13 @@ export const actionFinalize = register<FormData>({
|
||||
// locked via the tool lock or host-forced (`props.activeTool`)
|
||||
const isToolLocked = app.isToolLocked();
|
||||
|
||||
if (
|
||||
(!isToolLocked && appState.activeTool.type !== "freedraw") ||
|
||||
!element
|
||||
) {
|
||||
// the drawShape flow finalizes without a `newElement` (the sketch is
|
||||
// converted separately), so it stays active regardless of `element`
|
||||
const keepActiveTool =
|
||||
appState.activeTool.type === "autoshape" ||
|
||||
((isToolLocked || appState.activeTool.type === "freedraw") && !!element);
|
||||
|
||||
if (!keepActiveTool) {
|
||||
// the active tool reverts (see the returned `appState.activeTool`) —
|
||||
// apply the reverted tool's cursor
|
||||
app.cursor.applyForTool(activeTool);
|
||||
@@ -366,26 +394,25 @@ export const actionFinalize = register<FormData>({
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(isToolLocked || appState.activeTool.type === "freedraw") && element
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeTool: keepActiveTool ? appState.activeTool : activeTool,
|
||||
activeEmbeddable: null,
|
||||
newElement: null,
|
||||
selectionElement: null,
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds:
|
||||
element && !isToolLocked && appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[element.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
selectedElementIds: isDrawShapeTool
|
||||
? {}
|
||||
: element && !isToolLocked && appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[element.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
|
||||
selectedLinearElement,
|
||||
selectedLinearElement: isDrawShapeTool ? null : selectedLinearElement,
|
||||
},
|
||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||
captureUpdate: shouldCommit
|
||||
|
||||
@@ -49,16 +49,20 @@ import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, UIAppState } from "../types";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
const allElementsInSameGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
editingGroupId: UIAppState["editingGroupId"],
|
||||
) => {
|
||||
if (elements.length >= 2) {
|
||||
const groupIds = elements[0].groupIds;
|
||||
const editingGroupIndex = editingGroupId
|
||||
? elements[0].groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const groupIds =
|
||||
editingGroupIndex > -1
|
||||
? elements[0].groupIds.slice(0, editingGroupIndex)
|
||||
: elements[0].groupIds;
|
||||
for (const groupId of groupIds) {
|
||||
if (
|
||||
elements.reduce(
|
||||
(acc, element) => acc && isElementInGroup(element, groupId),
|
||||
true,
|
||||
)
|
||||
) {
|
||||
if (elements.every((element) => isElementInGroup(element, groupId))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -66,19 +70,15 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const enableActionGroup = (appState: UIAppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
|
||||
return (
|
||||
selectedElements.length >= 2 &&
|
||||
!allElementsInSameGroup(selectedElements) &&
|
||||
!allElementsInSameGroup(selectedElements, appState.editingGroupId) &&
|
||||
!frameAndChildrenSelectedTogether(selectedElements)
|
||||
);
|
||||
};
|
||||
@@ -89,6 +89,10 @@ export const actionGroup = register({
|
||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
if (!enableActionGroup(appState, app)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedElements = getRootElements(
|
||||
app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
@@ -194,13 +198,12 @@ export const actionGroup = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) =>
|
||||
enableActionGroup(elements, appState, app),
|
||||
predicate: (elements, appState, _, app) => enableActionGroup(appState, app),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<IconButton
|
||||
hidden={!enableActionGroup(elements, appState, app)}
|
||||
hidden={!enableActionGroup(appState, app)}
|
||||
type="button"
|
||||
icon={<GroupIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
|
||||
@@ -35,7 +35,11 @@ const executeHistoryAction = (
|
||||
!appState.newElement &&
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
!appState.selectionElement &&
|
||||
!app.flowchart.isCreatingChart
|
||||
!app.flowchart.isCreatingChart &&
|
||||
// a drawShape sketch in progress isn't visible via `newElement` while
|
||||
// the stroke is still unrecognized — block history mid-gesture the same
|
||||
// way as for the other in-progress interactions above
|
||||
!app.drawShape.hasPendingGesture()
|
||||
) {
|
||||
const result = updater();
|
||||
|
||||
|
||||
@@ -53,20 +53,16 @@ export const actionToggleLinearEditor = register({
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
invariant(selectedElement, "No selected element found");
|
||||
invariant(
|
||||
appState.selectedLinearElement,
|
||||
"No selected linear element found",
|
||||
);
|
||||
invariant(
|
||||
selectedElement.id === appState.selectedLinearElement.elementId,
|
||||
"Selected element ID and linear editor elementId does not match",
|
||||
);
|
||||
|
||||
const selectedElement = app.scene.getElement(
|
||||
appState.selectedLinearElement.elementId,
|
||||
) as ExcalidrawLinearElement | null;
|
||||
|
||||
invariant(selectedElement, "No selected element found");
|
||||
|
||||
const selectedLinearElement = {
|
||||
...appState.selectedLinearElement,
|
||||
|
||||
@@ -23,7 +23,7 @@ export const actionGoToCollaborator = register<Collaborator>({
|
||||
label: "Go to a collaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, collaborator) => {
|
||||
perform: (_elements, appState, collaborator, app) => {
|
||||
invariant(
|
||||
collaborator,
|
||||
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
|
||||
@@ -31,25 +31,27 @@ export const actionGoToCollaborator = register<Collaborator>({
|
||||
|
||||
if (
|
||||
!collaborator.socketId ||
|
||||
appState.userToFollow?.socketId === collaborator.socketId ||
|
||||
app.props.userToFollow?.socketId === collaborator.socketId ||
|
||||
collaborator.isCurrentUser
|
||||
) {
|
||||
app.requestUnfollow();
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
app.emitUserFollowIntent({
|
||||
userToFollow: {
|
||||
socketId: collaborator.socketId,
|
||||
username: collaborator.username || "",
|
||||
},
|
||||
action: "FOLLOW",
|
||||
});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: {
|
||||
socketId: collaborator.socketId,
|
||||
username: collaborator.username || "",
|
||||
},
|
||||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
@@ -96,7 +98,11 @@ export const actionGoToCollaborator = register<Collaborator>({
|
||||
<div
|
||||
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||
onClick={() => updateData<Collaborator>(collaborator)}
|
||||
onClick={
|
||||
collaborator.isCurrentUser
|
||||
? undefined
|
||||
: () => updateData<Collaborator>(collaborator)
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
color={background}
|
||||
@@ -117,6 +123,7 @@ export const actionGoToCollaborator = register<Collaborator>({
|
||||
{eyeIcon}
|
||||
</div>
|
||||
)}
|
||||
{collaborator.isCurrentUser && ` (${t("labels.you")})`}
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,18 @@ describe("element locking", () => {
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should not show fill style for sticky note tool", () => {
|
||||
UI.clickTool("stickynote");
|
||||
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
|
||||
currentItemFillStyle: "hachure",
|
||||
});
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should show horizontal text align for text tool", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
@@ -110,6 +122,19 @@ describe("element locking", () => {
|
||||
expect(crossHatchButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should not show fill style for selected sticky note", () => {
|
||||
const stickyNote = API.createElement({
|
||||
type: "stickynote",
|
||||
backgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
|
||||
fillStyle: "hachure",
|
||||
});
|
||||
API.setElements([stickyNote]);
|
||||
API.setSelectedElements([stickyNote]);
|
||||
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should highlight common stroke width of selected elements", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
|
||||
@@ -3,10 +3,9 @@ import { pointFrom } from "@excalidraw/math";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
ARROW_TYPE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -29,8 +28,10 @@ import {
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
normalizeStickyNote,
|
||||
getNonDeletedElements,
|
||||
isNonDeletedElement,
|
||||
syncStickyNoteInk,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
@@ -46,6 +47,8 @@ import { getArrowheadForPicker } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -55,11 +58,17 @@ import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { hasStrokeColor } from "@excalidraw/element";
|
||||
import {
|
||||
getColorTargetElement,
|
||||
getColorUpdate,
|
||||
hasFillStyle,
|
||||
hasStrokeColor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
@@ -162,6 +171,10 @@ import {
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import {
|
||||
getColorTargetAppStateUpdates,
|
||||
resolveColorTarget,
|
||||
} from "./colorTargets";
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
@@ -201,7 +214,13 @@ export const changeProperty = (
|
||||
"[NONDELETED][INVARIANT] changeProperty(): skipping deleted selected/editing element",
|
||||
);
|
||||
}
|
||||
return callback(element as NonDeletedExcalidrawElement);
|
||||
const nextElement = callback(element as NonDeletedExcalidrawElement);
|
||||
// sticky notes keep their data invariants (never-transparent colors,
|
||||
// solid fill, minimum size) whatever property was written; a no-op
|
||||
// normalization returns the same object
|
||||
return isStickyNoteElement(nextElement)
|
||||
? normalizeStickyNote(nextElement)
|
||||
: nextElement;
|
||||
}
|
||||
return element;
|
||||
});
|
||||
@@ -280,6 +299,7 @@ const changeFontSize = (
|
||||
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
||||
) => {
|
||||
const newFontSizes = new Set<number>();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
const updatedElements = changeProperty(
|
||||
elements,
|
||||
@@ -288,15 +308,13 @@ const changeFontSize = (
|
||||
if (isTextElement(oldElement)) {
|
||||
const newFontSize = getNewFontSize(oldElement);
|
||||
newFontSizes.add(newFontSize);
|
||||
const container = app.scene.getContainerElement(oldElement);
|
||||
|
||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene,
|
||||
let newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
getBaseFontSizeUpdate(oldElement, newFontSize, elementsMap),
|
||||
);
|
||||
redrawTextBoundingBox(newElement, container, app.scene);
|
||||
|
||||
newElement = offsetElementAfterFontResize(
|
||||
oldElement,
|
||||
@@ -338,58 +356,75 @@ const changeFontSize = (
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const actionChangeStrokeColor = register<
|
||||
Pick<AppState, "currentItemStrokeColor">
|
||||
Partial<AppState> & { color?: string }
|
||||
>({
|
||||
name: "changeStrokeColor",
|
||||
label: "labels.stroke",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const { color, ...appStateUpdates } = value ?? {};
|
||||
if (color === undefined) {
|
||||
return {
|
||||
appState: { ...appState, ...appStateUpdates },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
// resolved from the state the action runs against — never from the
|
||||
// render-time closure of the memoized picker (see `resolveColorTarget`)
|
||||
const target = resolveColorTarget(appState, elements, "strokeColor");
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
return {
|
||||
...(value?.currentItemStrokeColor && {
|
||||
elements: changeProperty(
|
||||
// a note and its label share one ink: coloring the label while editing
|
||||
// it (no selection) colors the note too — the footer paints with it
|
||||
elements: syncStickyNoteInk(
|
||||
changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(el) => {
|
||||
return hasStrokeColor(el.type)
|
||||
? newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
})
|
||||
: el;
|
||||
},
|
||||
(el) =>
|
||||
hasStrokeColor(el.type)
|
||||
? newElementWith(
|
||||
el,
|
||||
getColorUpdate(el, "strokeColor", color, elementsMap),
|
||||
)
|
||||
: el,
|
||||
true,
|
||||
),
|
||||
}),
|
||||
elementsMap,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
...appStateUpdates,
|
||||
...getColorTargetAppStateUpdates(target, color),
|
||||
},
|
||||
captureUpdate: !!value?.currentItemStrokeColor
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const { stylesPanelMode } = getStylesPanelInfo(app);
|
||||
const target = resolveColorTarget(appState, elements, "strokeColor");
|
||||
// a note has no stroke: its "stroke" is the ink of its text and footer
|
||||
const label =
|
||||
target.kind === "sticky" ? t("labels.textColor") : t("labels.stroke");
|
||||
|
||||
return (
|
||||
<>
|
||||
{stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
)}
|
||||
{stylesPanelMode === "full" && <h3 aria-hidden="true">{label}</h3>}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||
topPicks={target.topPicks}
|
||||
palette={target.palette}
|
||||
customizableTopPicks={target.customizableTopPicks}
|
||||
excludedColors={target.excludedColors}
|
||||
type="elementStroke"
|
||||
label={t("labels.stroke")}
|
||||
label={label}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.strokeColor,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
!hasSelection ? appState.currentItemStrokeColor : null,
|
||||
(hasSelection) => (!hasSelection ? target.currentValue : null),
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
onChange={(color) => updateData({ color })}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
@@ -400,27 +435,29 @@ export const actionChangeStrokeColor = register<
|
||||
});
|
||||
|
||||
export const actionChangeBackgroundColor = register<
|
||||
Pick<AppState, "currentItemBackgroundColor" | "viewBackgroundColor">
|
||||
Partial<AppState> & { color?: string }
|
||||
>({
|
||||
name: "changeBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (!value?.currentItemBackgroundColor) {
|
||||
const { color, ...appStateUpdates } = value ?? {};
|
||||
if (color === undefined) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
appState: { ...appState, ...appStateUpdates },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
const target = resolveColorTarget(appState, elements, "backgroundColor");
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
let nextElements;
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const shouldEnablePolygon =
|
||||
!isTransparent(value.currentItemBackgroundColor) &&
|
||||
!isTransparent(color) &&
|
||||
// `every` is vacuously true with nothing selected (editing text)
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every(
|
||||
(el) => isLineElement(el) && canBecomePolygon(el.points),
|
||||
);
|
||||
@@ -430,7 +467,7 @@ export const actionChangeBackgroundColor = register<
|
||||
nextElements = elements.map((el) => {
|
||||
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
|
||||
return newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
backgroundColor: color,
|
||||
...toggleLinePolygonState(el, true),
|
||||
});
|
||||
}
|
||||
@@ -438,22 +475,100 @@ export const actionChangeBackgroundColor = register<
|
||||
});
|
||||
} else {
|
||||
nextElements = changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
// a note's label passes the pick on to the note (below)
|
||||
getColorTargetElement(el, "backgroundColor", elementsMap) === el
|
||||
? newElementWith(
|
||||
el,
|
||||
getColorUpdate(el, "backgroundColor", color, elementsMap),
|
||||
)
|
||||
: el,
|
||||
);
|
||||
// editing a note's label (no selection): the label has no fill, the
|
||||
// pick colors the note — text and background in one editing pass
|
||||
const editingText =
|
||||
appState.editingTextElement &&
|
||||
elementsMap.get(appState.editingTextElement.id);
|
||||
const editingTarget =
|
||||
editingText &&
|
||||
getColorTargetElement(editingText, "backgroundColor", elementsMap);
|
||||
if (editingTarget && editingTarget !== editingText) {
|
||||
nextElements = nextElements.map((el) =>
|
||||
el.id === editingTarget.id
|
||||
? newElementWith(
|
||||
el,
|
||||
getColorUpdate(el, "backgroundColor", color, elementsMap),
|
||||
)
|
||||
: el,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
...appStateUpdates,
|
||||
...getColorTargetAppStateUpdates(target, color),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const { stylesPanelMode } = getStylesPanelInfo(app);
|
||||
const target = resolveColorTarget(appState, elements, "backgroundColor");
|
||||
// while editing a note's label the picker shows and sets the note's fill
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
return (
|
||||
<>
|
||||
{stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={target.topPicks}
|
||||
palette={target.palette}
|
||||
customizableTopPicks={target.customizableTopPicks}
|
||||
excludedColors={target.excludedColors}
|
||||
type="elementBackground"
|
||||
label={t("labels.background")}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
getColorTargetElement(element, "backgroundColor", elementsMap)
|
||||
.backgroundColor,
|
||||
true,
|
||||
(hasSelection) => (!hasSelection ? target.currentValue : null),
|
||||
)}
|
||||
onChange={(color) => updateData({ color })}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeBucketFillBackgroundColor = register<
|
||||
Pick<AppState, "currentItemBackgroundColor">
|
||||
>({
|
||||
name: "changeBucketFillBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
// the bucket fill tool has no element to mutate; it shares
|
||||
// `currentItemBackgroundColor` but hides `transparent` (an invisible fill
|
||||
// would be a no-op) and shows the effective fallback color instead
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const { stylesPanelMode } = getStylesPanelInfo(app);
|
||||
|
||||
return (
|
||||
@@ -462,17 +577,19 @@ export const actionChangeBackgroundColor = register<
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||
topPicks={BUCKET_FILL_BACKGROUND_PICKS}
|
||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||
// hidden rather than removed from the palette so the remaining
|
||||
// colors keep their usual hotkeys (w for white etc.)
|
||||
excludedColors={[COLOR_PALETTE.transparent]}
|
||||
// customized independently of the background picker's picks even
|
||||
// though both drive `currentItemBackgroundColor` — the bucket
|
||||
// strip's defaults and use case differ (no transparent)
|
||||
customizableTopPicks="bucketFill"
|
||||
type="elementBackground"
|
||||
label={t("labels.background")}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.backgroundColor,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
!hasSelection ? appState.currentItemBackgroundColor : null,
|
||||
color={app.bucketFill.getBucketFillBackgroundColor(
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) =>
|
||||
updateData({ currentItemBackgroundColor: color })
|
||||
@@ -500,9 +617,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
fillStyle: value,
|
||||
}),
|
||||
hasFillStyle(el.type) ? newElementWith(el, { fillStyle: value }) : el,
|
||||
),
|
||||
appState: { ...appState, currentItemFillStyle: value },
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -510,9 +625,12 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const selectedFillStyleElements = selectedElements.filter((element) =>
|
||||
hasFillStyle(element.type),
|
||||
);
|
||||
const allElementsZigZag =
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every((el) => el.fillStyle === "zigzag");
|
||||
selectedFillStyleElements.length > 0 &&
|
||||
selectedFillStyleElements.every((el) => el.fillStyle === "zigzag");
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
@@ -547,7 +665,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
elements,
|
||||
app,
|
||||
(element) => element.fillStyle,
|
||||
(element) => element.hasOwnProperty("fillStyle"),
|
||||
(element) => hasFillStyle(element.type),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemFillStyle,
|
||||
)}
|
||||
@@ -555,7 +673,9 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
const nextValue =
|
||||
event.altKey &&
|
||||
value === "hachure" &&
|
||||
selectedElements.every((el) => el.fillStyle === "hachure")
|
||||
selectedFillStyleElements.every(
|
||||
(el) => el.fillStyle === "hachure",
|
||||
)
|
||||
? "zigzag"
|
||||
: value;
|
||||
|
||||
@@ -932,15 +1052,16 @@ export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
|
||||
elements,
|
||||
app,
|
||||
(element) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
if (isTextElement(element)) {
|
||||
return element.fontSize;
|
||||
return getBaseFontSize(element, elementsMap);
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
elementsMap,
|
||||
);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontSize;
|
||||
return getBaseFontSize(boundTextElement, elementsMap);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -981,7 +1102,8 @@ export const actionDecreaseFontSize = register({
|
||||
Math.round(
|
||||
// get previous value before relative increase (doesn't work fully
|
||||
// due to rounding and float precision issues)
|
||||
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
|
||||
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) *
|
||||
getBaseFontSize(element, app.scene.getNonDeletedElementsMap()),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1002,7 +1124,10 @@ export const actionIncreaseFontSize = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, (element) =>
|
||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||
Math.round(
|
||||
getBaseFontSize(element, app.scene.getNonDeletedElementsMap()) *
|
||||
(1 + FONT_SIZE_RELATIVE_INCREASE_STEP),
|
||||
),
|
||||
);
|
||||
},
|
||||
keyTest: (event) => {
|
||||
@@ -1194,8 +1319,9 @@ export const actionChangeFontFamily = register<{
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const chars = Array.from(uniqueChars.values()).join();
|
||||
const ownerDocument = app.props.ownerDocument ?? document;
|
||||
|
||||
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
||||
if (skipFontFaceCheck || ownerDocument.fonts.check(fontString, chars)) {
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
@@ -1203,7 +1329,7 @@ export const actionChangeFontFamily = register<{
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
||||
ownerDocument.fonts.load(fontString, chars).then((fontFaces) => {
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
@@ -1403,7 +1529,7 @@ export const actionChangeFontFamily = register<{
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (isCompact && appState.editingTextElement) {
|
||||
restoreCaretPosition(null); // Just refocus without saved position
|
||||
restoreCaretPosition(null, app.ownerDocument); // Just refocus without saved position
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -1625,7 +1751,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
return el;
|
||||
}
|
||||
|
||||
return newElementWith(el, {
|
||||
const nextElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === "round"
|
||||
? {
|
||||
@@ -1635,6 +1761,8 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
return nextElement;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -5,18 +5,28 @@ import {
|
||||
CODES,
|
||||
KEYS,
|
||||
getLineHeight,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith, syncStickyNoteInk } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
normalizeStickyNote,
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameLikeElement,
|
||||
isArrowElement,
|
||||
isExcalidrawElement,
|
||||
getColorUpdate,
|
||||
isNonDeletedElement,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
relayoutStickyNotes,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
@@ -86,84 +96,133 @@ export const actionPasteStyles = register({
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
roundness: elementStylesToCopyFrom.roundness
|
||||
? canApplyRoundnessTypeToElement(
|
||||
elementStylesToCopyFrom.roundness.type,
|
||||
element,
|
||||
)
|
||||
? elementStylesToCopyFrom.roundness
|
||||
: getDefaultRoundnessTypeForElement(element)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
|
||||
DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
|
||||
DEFAULT_FONT_FAMILY;
|
||||
const newTextElement = newElementWith(newElement, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getLineHeight(fontFamily),
|
||||
const elementsMap = arrayToMap(elements);
|
||||
// whether the copied text was a sticky label is decided by the copied
|
||||
// snapshot — its container may be gone from the live scene by now
|
||||
const copiedElementsMap = arrayToMap(
|
||||
(elementsCopied as unknown[]).filter(isExcalidrawElement),
|
||||
);
|
||||
const nextElements = relayoutStickyNotes(
|
||||
// a restyled note and its label end up with one ink — the label's,
|
||||
// when the copied styles carry two colors
|
||||
syncStickyNoteInk(
|
||||
elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
roundness: elementStylesToCopyFrom.roundness
|
||||
? canApplyRoundnessTypeToElement(
|
||||
elementStylesToCopyFrom.roundness.type,
|
||||
element,
|
||||
)
|
||||
? elementStylesToCopyFrom.roundness
|
||||
: getDefaultRoundnessTypeForElement(element)
|
||||
: null,
|
||||
});
|
||||
newElement = newTextElement;
|
||||
let container = null;
|
||||
if (newTextElement.containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) => element.id === newTextElement.containerId,
|
||||
) || null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const sourceText =
|
||||
elementStylesToCopyFrom as ExcalidrawTextElement;
|
||||
const fontSize =
|
||||
(isTextElement(elementStylesToCopyFrom)
|
||||
? getBaseFontSize(elementStylesToCopyFrom, copiedElementsMap)
|
||||
: sourceText.fontSize) || DEFAULT_FONT_SIZE;
|
||||
const fontFamily = sourceText.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
let container = null;
|
||||
const containerId = newElement.containerId;
|
||||
if (containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) => element.id === containerId,
|
||||
) || null;
|
||||
}
|
||||
const newTextElement = newElementWith(newElement, {
|
||||
...getBaseFontSizeUpdate(newElement, fontSize, elementsMap),
|
||||
fontFamily,
|
||||
textAlign: sourceText.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight: sourceText.lineHeight || getLineHeight(fontFamily),
|
||||
});
|
||||
newElement = newTextElement;
|
||||
|
||||
if (isStickyNoteBoundText(newTextElement, elementsMap)) {
|
||||
// the copied stroke may be transparent; a note's label never is
|
||||
newElement = newElementWith(
|
||||
newTextElement,
|
||||
getColorUpdate(
|
||||
newTextElement,
|
||||
"strokeColor",
|
||||
newTextElement.strokeColor,
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// sticky labels are laid out together with their (possibly
|
||||
// also restyled) note in the post-pass below
|
||||
redrawTextBoundingBox(newTextElement, container, app.scene);
|
||||
}
|
||||
}
|
||||
|
||||
redrawTextBoundingBox(newTextElement, container, app.scene);
|
||||
}
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
}
|
||||
if (isFrameLikeElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
if (isStickyNoteElement(newElement)) {
|
||||
newElement = normalizeStickyNote(newElement);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
elementsMap,
|
||||
),
|
||||
new Set(selectedElementIds),
|
||||
{ prevElementsMap: elementsMap },
|
||||
);
|
||||
|
||||
// a restyled note may have grown or shrunk — arrows bound to it follow
|
||||
for (const element of nextElements) {
|
||||
const prev = elementsMap.get(element.id);
|
||||
if (
|
||||
isStickyNoteElement(element) &&
|
||||
isNonDeletedElement(element) &&
|
||||
prev &&
|
||||
(prev.height !== element.height ||
|
||||
prev.x !== element.x ||
|
||||
prev.y !== element.y)
|
||||
) {
|
||||
updateBoundElements(element, app.scene);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
|
||||
import {
|
||||
getTextAnchorRatios,
|
||||
isExcalidrawElement,
|
||||
isNonDeletedElement,
|
||||
newElementWith,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
@@ -26,7 +32,7 @@ export const actionTextAutoResize = register({
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, targetElement) => {
|
||||
perform: (elements, appState, targetElement, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const targetTextElement =
|
||||
@@ -34,25 +40,48 @@ export const actionTextAutoResize = register({
|
||||
? targetElement
|
||||
: (selectedElements[0] as ExcalidrawElement | undefined);
|
||||
|
||||
const target = elements.find(
|
||||
(element) => element.id === targetTextElement?.id,
|
||||
);
|
||||
|
||||
if (!target || !isTextElement(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
target.originalText,
|
||||
getFontString(target),
|
||||
target.lineHeight,
|
||||
);
|
||||
|
||||
// unwrapping resizes the box, so keep the point the text's alignment pins
|
||||
// — otherwise a right-aligned or centred text slides sideways, and one
|
||||
// bound to an arrow endpoint drags the arrow with it
|
||||
const anchor = getTextAnchorRatios(target);
|
||||
|
||||
const resized = newElementWith(target, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
x: target.x + (target.width - metrics.width) * anchor.x,
|
||||
y: target.y + (target.height - metrics.height) * anchor.y,
|
||||
text: target.originalText,
|
||||
});
|
||||
|
||||
// the anchor only pins one point of the box, so any other arrow bound to
|
||||
// this text still has to be re-routed to its new geometry. The scene holds
|
||||
// the pre-resize text, hence handing the new one over explicitly.
|
||||
if (isNonDeletedElement(resized)) {
|
||||
updateBoundElements(resized, app.scene, {
|
||||
changedElements: new Map([[resized.id, resized]]),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === targetTextElement?.id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
text: element.originalText,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
elements: elements.map((element) =>
|
||||
element.id === resized.id ? resized : element,
|
||||
),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
STICKY_NOTE_BACKGROUND_PICKS,
|
||||
STICKY_NOTE_STROKE_PICKS,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getColorTargetElement,
|
||||
hasBackground,
|
||||
hasStrokeColor,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
normalizeStickyNoteBackgroundColor,
|
||||
normalizeStickyNoteStrokeColor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ColorPaletteCustom, ColorTuple } from "@excalidraw/common";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export type ColorProperty = "strokeColor" | "backgroundColor";
|
||||
|
||||
/**
|
||||
* Sticky notes are their own color domain: own defaults, own top picks, no
|
||||
* transparent. A pick targets the regular domain, the sticky domain, or both
|
||||
* ("mixed" selections write both defaults and use the regular picker).
|
||||
*/
|
||||
export type ColorTargetKind = "regular" | "sticky" | "mixed";
|
||||
|
||||
export type ColorDefaultKey =
|
||||
| "currentItemStrokeColor"
|
||||
| "currentItemBackgroundColor"
|
||||
| "currentItemStickynoteStrokeColor"
|
||||
| "currentItemStickynoteBackgroundColor";
|
||||
|
||||
export type ColorTargetAppState = Pick<
|
||||
AppState,
|
||||
"selectedElementIds" | "editingTextElement" | "activeTool" | ColorDefaultKey
|
||||
>;
|
||||
|
||||
export type ColorTarget = {
|
||||
kind: ColorTargetKind;
|
||||
property: ColorProperty;
|
||||
/** the current-item defaults a pick is written to (normalized per domain) */
|
||||
appStateKeys: readonly ColorDefaultKey[];
|
||||
/** the default shown when no element is targeted */
|
||||
currentValue: string;
|
||||
palette: ColorPaletteCustom;
|
||||
topPicks: ColorTuple;
|
||||
customizableTopPicks: keyof AppState["colorTopPicks"];
|
||||
excludedColors: readonly string[] | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_KEYS: Record<
|
||||
ColorProperty,
|
||||
Record<"regular" | "sticky", ColorDefaultKey>
|
||||
> = {
|
||||
strokeColor: {
|
||||
regular: "currentItemStrokeColor",
|
||||
sticky: "currentItemStickynoteStrokeColor",
|
||||
},
|
||||
backgroundColor: {
|
||||
regular: "currentItemBackgroundColor",
|
||||
sticky: "currentItemStickynoteBackgroundColor",
|
||||
},
|
||||
};
|
||||
|
||||
// transparent is hidden rather than removed from the palette so the remaining
|
||||
// colors keep their usual hotkeys (same mechanism as the bucket fill picker);
|
||||
// a module constant so the memoized picker's identity comparison holds
|
||||
const STICKY_NOTE_EXCLUDED_COLORS: readonly string[] = [
|
||||
COLOR_PALETTE.transparent,
|
||||
];
|
||||
|
||||
const isStickyNoteColorTarget = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
isStickyNoteElement(element) ||
|
||||
(isTextElement(element) && isStickyNoteBoundText(element, elementsMap));
|
||||
|
||||
/**
|
||||
* Who a stroke/background pick targets. Resolve it from the state an action
|
||||
* runs against, at execution time — never capture it in a render closure:
|
||||
* the memoized picker keeps a stale `onChange`, and the always-visible top
|
||||
* picks fire it without a re-render.
|
||||
*
|
||||
* Targets are the selected color-capable elements (for stroke incl. bound
|
||||
* labels, since a note's visible text is its label) plus the text being
|
||||
* edited (`handleTextWysiwyg` deselects while editing) — or, for a
|
||||
* background pick on a note's label, the note (see `getColorTargetElement`).
|
||||
* With no target, the active tool decides the domain.
|
||||
*/
|
||||
export const resolveColorTarget = (
|
||||
appState: ColorTargetAppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
property: ColorProperty,
|
||||
): ColorTarget => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const supports = (element: ExcalidrawElement) =>
|
||||
property === "strokeColor"
|
||||
? hasStrokeColor(element.type)
|
||||
: hasBackground(element.type);
|
||||
|
||||
const targets: ExcalidrawElement[] = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: property === "strokeColor",
|
||||
}).filter(supports);
|
||||
const editingText =
|
||||
appState.editingTextElement &&
|
||||
elementsMap.get(appState.editingTextElement.id);
|
||||
const editing =
|
||||
editingText && getColorTargetElement(editingText, property, elementsMap);
|
||||
if (
|
||||
editing &&
|
||||
!editing.isDeleted &&
|
||||
supports(editing) &&
|
||||
!targets.some((element) => element.id === editing.id)
|
||||
) {
|
||||
targets.push(editing);
|
||||
}
|
||||
|
||||
let kind: ColorTargetKind;
|
||||
if (!targets.length) {
|
||||
kind = appState.activeTool.type === "stickynote" ? "sticky" : "regular";
|
||||
} else {
|
||||
const stickyCount = targets.filter((element) =>
|
||||
isStickyNoteColorTarget(element, elementsMap),
|
||||
).length;
|
||||
kind =
|
||||
stickyCount === 0
|
||||
? "regular"
|
||||
: stickyCount === targets.length
|
||||
? "sticky"
|
||||
: "mixed";
|
||||
}
|
||||
|
||||
const keys = DEFAULT_KEYS[property];
|
||||
const isStroke = property === "strokeColor";
|
||||
|
||||
return {
|
||||
kind,
|
||||
property,
|
||||
appStateKeys: kind === "mixed" ? [keys.regular, keys.sticky] : [keys[kind]],
|
||||
currentValue: appState[kind === "sticky" ? keys.sticky : keys.regular],
|
||||
palette: isStroke
|
||||
? DEFAULT_ELEMENT_STROKE_COLOR_PALETTE
|
||||
: DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
topPicks:
|
||||
kind === "sticky"
|
||||
? isStroke
|
||||
? STICKY_NOTE_STROKE_PICKS
|
||||
: STICKY_NOTE_BACKGROUND_PICKS
|
||||
: isStroke
|
||||
? DEFAULT_ELEMENT_STROKE_PICKS
|
||||
: DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
customizableTopPicks:
|
||||
kind === "sticky"
|
||||
? isStroke
|
||||
? "stickyNoteStroke"
|
||||
: "stickyNoteBackground"
|
||||
: isStroke
|
||||
? "elementStroke"
|
||||
: "elementBackground",
|
||||
excludedColors: kind === "sticky" ? STICKY_NOTE_EXCLUDED_COLORS : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** the current-item default updates for a picked color */
|
||||
export const getColorTargetAppStateUpdates = (
|
||||
target: ColorTarget,
|
||||
color: string,
|
||||
): Partial<AppState> => {
|
||||
const updates: Partial<AppState> = {};
|
||||
for (const key of target.appStateKeys) {
|
||||
updates[key] =
|
||||
key === "currentItemStickynoteStrokeColor"
|
||||
? normalizeStickyNoteStrokeColor(color)
|
||||
: key === "currentItemStickynoteBackgroundColor"
|
||||
? normalizeStickyNoteBackgroundColor(color)
|
||||
: color;
|
||||
}
|
||||
return updates;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export { actionDuplicateSelection } from "./actionDuplicateSelection";
|
||||
export {
|
||||
actionChangeStrokeColor,
|
||||
actionChangeBackgroundColor,
|
||||
actionChangeBucketFillBackgroundColor,
|
||||
actionChangeStrokeWidth,
|
||||
actionChangeFillStyle,
|
||||
actionChangeSloppiness,
|
||||
|
||||
@@ -192,14 +192,21 @@ export class ActionManager {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
PanelComponent.displayName = "PanelComponent";
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
if (this.isActionBlockedByViewportTransition(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||
// read fresh state at call time — memoized panel children may invoke
|
||||
// an `updateData` closure minted by an earlier render
|
||||
trackAction(
|
||||
action,
|
||||
"ui",
|
||||
this.getAppState(),
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.app,
|
||||
formState,
|
||||
);
|
||||
|
||||
this.updater(
|
||||
action.perform(
|
||||
|
||||
@@ -64,9 +64,9 @@ export type ActionName =
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
| "changeBucketFillBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeFreedrawMode"
|
||||
| "changeStrokeStyle"
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set([
|
||||
"command_palette",
|
||||
"export",
|
||||
"ai",
|
||||
]);
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
THEME,
|
||||
DEFAULT_GRID_STEP,
|
||||
isTestEnv,
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
import type { AppState, InputDevice, NormalizedZoomValue } from "./types";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
@@ -38,6 +39,8 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemStrokeVariability: "constant",
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemStickynoteStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemStickynoteBackgroundColor: DEFAULT_STICKY_NOTE_BG,
|
||||
currentItemRoundness: isTestEnv() ? "sharp" : "round",
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
@@ -74,6 +77,7 @@ export const getDefaultAppState = (): Omit<
|
||||
isBindingEnabled: true,
|
||||
bindingPreference: "enabled",
|
||||
isMidpointSnappingEnabled: true,
|
||||
inputDevice: "auto",
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
@@ -103,6 +107,7 @@ export const getDefaultAppState = (): Omit<
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
@@ -122,8 +127,6 @@ export const getDefaultAppState = (): Omit<
|
||||
y: 0,
|
||||
},
|
||||
objectsSnapModeEnabled: false,
|
||||
userToFollow: null,
|
||||
followedBy: new Set(),
|
||||
isCropping: false,
|
||||
croppingElementId: null,
|
||||
searchMatches: null,
|
||||
@@ -131,6 +134,13 @@ export const getDefaultAppState = (): Omit<
|
||||
activeLockedId: null,
|
||||
bindMode: "orbit",
|
||||
boxSelectionMode: "contain",
|
||||
colorTopPicks: {
|
||||
elementStroke: null,
|
||||
elementBackground: null,
|
||||
bucketFill: null,
|
||||
stickyNoteStroke: null,
|
||||
stickyNoteBackground: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -177,6 +187,16 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
},
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStickynoteStrokeColor: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemStickynoteBackgroundColor: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidthKey: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
@@ -204,6 +224,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
boxSelectionMode: { browser: true, export: false, server: false },
|
||||
bindingPreference: { browser: true, export: false, server: false },
|
||||
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
|
||||
inputDevice: { browser: true, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
browser: true,
|
||||
export: false,
|
||||
@@ -240,6 +261,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
suggestedBinding: { browser: false, export: false, server: false },
|
||||
hoveredArrowTextAnchor: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
@@ -255,14 +277,13 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
snapLines: { browser: false, export: false, server: false },
|
||||
originSnapOffset: { browser: false, export: false, server: false },
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
userToFollow: { browser: false, export: false, server: false },
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||
activeLockedId: { browser: false, export: false, server: false },
|
||||
bindMode: { browser: true, export: false, server: false },
|
||||
colorTopPicks: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
@@ -314,3 +335,17 @@ export const isHandToolActive = ({
|
||||
}) => {
|
||||
return activeTool.type === "hand";
|
||||
};
|
||||
|
||||
/**
|
||||
* The device the wheel mappings follow for the given preference.
|
||||
*
|
||||
* `auto` is meant to detect the device from the wheel events themselves
|
||||
* (line vs. pixel delta modes, whole vs. fractional deltas, one vs. two axes
|
||||
* moving, event cadence and momentum tails). That is not implemented yet, so
|
||||
* it resolves to `trackpad` — the mapping the editor has always had, and the
|
||||
* default to keep until detection exists.
|
||||
*/
|
||||
export const resolveInputDevice = (
|
||||
inputDevice: InputDevice,
|
||||
): Exclude<InputDevice, "auto"> =>
|
||||
inputDevice === "auto" ? "trackpad" : inputDevice;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
COLOR_CHARCOAL_BLACK,
|
||||
COLOR_VOICE_CALL,
|
||||
COLOR_WHITE,
|
||||
THEME,
|
||||
UserIdleState,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -122,9 +121,7 @@ export const renderRemoteCursors = ({
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||
const IS_SPEAKING_COLOR =
|
||||
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||
const IS_SPEAKING_COLOR = COLOR_VOICE_CALL;
|
||||
|
||||
const isSpeaking = collaborator?.isSpeaking;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
import { CURRENT_SCHEMA_VERSION, getSchemaVersion } from "@excalidraw/element";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isInitializedImageElement,
|
||||
@@ -38,6 +39,7 @@ import type { BinaryFiles } from "./types";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
schemaVersion: number;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
@@ -46,6 +48,7 @@ export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
|
||||
|
||||
export interface ClipboardData {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
schemaVersion?: number;
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
mixedContent?: PastedMixedContent;
|
||||
@@ -172,6 +175,7 @@ export const serializeAsClipboardJSON = ({
|
||||
// select bound text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element, elementsMap) &&
|
||||
@@ -542,6 +546,7 @@ export const parseClipboard = async (
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
schemaVersion: getSchemaVersion(systemClipboardData),
|
||||
files: systemClipboardData.files,
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
|
||||
@@ -152,6 +152,18 @@ export const SelectedShapeActions = ({
|
||||
app,
|
||||
);
|
||||
|
||||
// the bucket fill tool configures only the fill it creates: color, fill
|
||||
// style, and opacity (shared `currentItem*` values; no stroke properties)
|
||||
if (appState.activeTool.type === "bucketfill") {
|
||||
return (
|
||||
<div className="selected-shape-actions">
|
||||
<div>{renderAction("changeBucketFillBackgroundColor")}</div>
|
||||
{renderAction("changeFillStyle")}
|
||||
{renderAction("changeOpacity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="selected-shape-actions">
|
||||
<div>{predicates.strokeColor && renderAction("changeStrokeColor")}</div>
|
||||
@@ -162,14 +174,11 @@ export const SelectedShapeActions = ({
|
||||
|
||||
{predicates.strokeWidth && renderAction("changeStrokeWidth")}
|
||||
|
||||
{predicates.strokeStyle && <>{renderAction("changeStrokeStyle")}</>}
|
||||
|
||||
{predicates.freedrawMode && renderAction("changeFreedrawMode")}
|
||||
|
||||
{predicates.strokeStyle && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
)}
|
||||
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
|
||||
|
||||
{predicates.roundness && <>{renderAction("changeRoundness")}</>}
|
||||
|
||||
@@ -186,9 +195,9 @@ export const SelectedShapeActions = ({
|
||||
{predicates.verticalAlign && renderAction("changeVerticalAlign")}
|
||||
{predicates.arrowheads && <>{renderAction("changeArrowhead")}</>}
|
||||
|
||||
{renderAction("changeOpacity")}
|
||||
{predicates.opacity && renderAction("changeOpacity")}
|
||||
|
||||
<LayersFieldset renderAction={renderAction} />
|
||||
{predicates.layers && <LayersFieldset renderAction={renderAction} />}
|
||||
|
||||
{predicates.align && (
|
||||
<AlignFieldset
|
||||
@@ -289,13 +298,11 @@ const CombinedShapeProperties = ({
|
||||
predicates.freedrawMode && renderAction("changeFreedrawMode")
|
||||
}
|
||||
{predicates.strokeStyle && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
<>{renderAction("changeStrokeStyle")}</>
|
||||
)}
|
||||
{predicates.sloppiness && <>{renderAction("changeSloppiness")}</>}
|
||||
{predicates.roundness && renderAction("changeRoundness")}
|
||||
{renderAction("changeOpacity")}
|
||||
{predicates.opacity && renderAction("changeOpacity")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
@@ -321,12 +328,12 @@ const CombinedArrowProperties = ({
|
||||
container: HTMLDivElement | null;
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const isOpen = appState.openPopup === "compactArrowProperties";
|
||||
|
||||
if (!predicates.arrowType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOpen = appState.openPopup === "compactArrowProperties";
|
||||
|
||||
return (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
@@ -413,7 +420,9 @@ const CombinedTextProperties = ({
|
||||
predicates: ShapeActionPredicates;
|
||||
container: HTMLDivElement | null;
|
||||
}) => {
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(
|
||||
container?.ownerDocument,
|
||||
);
|
||||
const isOpen = appState.openPopup === "compactTextProperties";
|
||||
|
||||
return (
|
||||
@@ -549,7 +558,9 @@ const CombinedExtraActions = ({
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
<LayersFieldset renderAction={renderAction} />
|
||||
{predicates.layers && (
|
||||
<LayersFieldset renderAction={renderAction} />
|
||||
)}
|
||||
|
||||
{predicates.align && (
|
||||
<AlignFieldset
|
||||
@@ -629,10 +640,14 @@ export const CompactShapeActions = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{/* Background Color (the bucket fill variant excludes `transparent`) */}
|
||||
{predicates.backgroundColor && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeBackgroundColor")}
|
||||
{renderAction(
|
||||
appState.activeTool.type === "bucketfill"
|
||||
? "changeBucketFillBackgroundColor"
|
||||
: "changeBackgroundColor",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -783,9 +798,14 @@ export const MobileShapeActions = ({
|
||||
{renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
)}
|
||||
{/* Background Color (the bucket fill variant excludes `transparent`) */}
|
||||
{predicates.backgroundColor && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeBackgroundColor")}
|
||||
{renderAction(
|
||||
appState.activeTool.type === "bucketfill"
|
||||
? "changeBucketFillBackgroundColor"
|
||||
: "changeBackgroundColor",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CombinedShapeProperties
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { CURSOR_TYPE, DRAGGING_THRESHOLD } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
bindBindingElementToFixedPoint,
|
||||
dragNewTextElement,
|
||||
getBoundTextElement,
|
||||
getEndpointBoundTextDragAnchor,
|
||||
getTextBindingForArrowEndpoint,
|
||||
getUnboundArrowEndpointAtPoint,
|
||||
hitElementBoundText,
|
||||
isArrowElement,
|
||||
isBindingEnabled,
|
||||
isEndpointBoundText,
|
||||
isTextElement,
|
||||
LinearElementEditor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { pointDistance, pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { ArrowEndpoint } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPoint,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type App from "./App";
|
||||
import type { AppState, PointerDownState } from "../types";
|
||||
|
||||
/**
|
||||
* Text ↔ arrow interactions.
|
||||
*
|
||||
* With the text tool: the hover affordance showing where a click would attach
|
||||
* text to an arrow — a free endpoint (binds the arrow to a new text element
|
||||
* positioned against that endpoint) or the arrow's midpoint (adds a label
|
||||
* bound to the arrow) — and the endpoint-bound flavor of drag-sizing a new
|
||||
* text.
|
||||
*
|
||||
* With the selection tool: dragging an arrow's existing label along the arrow
|
||||
* — the grab affordance, the drag itself, and where a double-click starts
|
||||
* editing a label the drag has moved.
|
||||
*
|
||||
* The scene-level logic lives in `@excalidraw/element`'s
|
||||
* `arrowEndpointText.ts` and `linearElementEditor.ts`.
|
||||
*/
|
||||
export class AppArrowText {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/**
|
||||
* With the text tool active, an arrow under the cursor is a target for
|
||||
* attaching text. Returns where the text would land (if anywhere), and keeps
|
||||
* `appState.hoveredArrowTextAnchor` — which drives the highlight — in sync.
|
||||
*/
|
||||
updateHoveredAnchor = (scenePointer: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): AppState["hoveredArrowTextAnchor"] => {
|
||||
const hovered =
|
||||
this.app.state.activeTool.type === "text" &&
|
||||
!this.app.state.editingTextElement &&
|
||||
!this.app.state.newElement
|
||||
? this.getAnchorAtPosition(scenePointer.x, scenePointer.y)
|
||||
: null;
|
||||
|
||||
const previous = this.app.state.hoveredArrowTextAnchor;
|
||||
|
||||
if (
|
||||
previous?.elementId !== hovered?.elementId ||
|
||||
previous?.anchor !== hovered?.anchor
|
||||
) {
|
||||
this.app.setState({ hoveredArrowTextAnchor: hovered });
|
||||
}
|
||||
|
||||
return hovered;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-evaluates the hovered anchor at the last known pointer position — for
|
||||
* events that change what a click would do without the pointer moving,
|
||||
* i.e. the ctrl/cmd binding toggle. (Events that invalidate the anchor
|
||||
* wholesale — tool switches, finalize, deselect — clear it directly
|
||||
* instead.)
|
||||
*/
|
||||
refresh = (): void => {
|
||||
if (this.app.lastPointerMoveCoords) {
|
||||
this.updateHoveredAnchor(this.app.lastPointerMoveCoords);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A free arrow endpoint a new text could be bound to. Binding an endpoint is
|
||||
* an arrow binding, so it follows the binding toggle (ctrl/cmd) like every
|
||||
* other one — holding it makes the text tool drop plain text instead.
|
||||
*/
|
||||
getBindableEndpointAtPosition(x: number, y: number): ArrowEndpoint | null {
|
||||
if (!isBindingEnabled(this.app.state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getUnboundArrowEndpointAtPoint(
|
||||
pointFrom(x, y),
|
||||
this.app.scene.getNonDeletedElements(),
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
this.app.state.zoom,
|
||||
);
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The text tool edits before it creates: when a text element is the
|
||||
// top-most hit at this position, a click edits that text, so a nearby
|
||||
// endpoint must not be offered over it.
|
||||
if (this.app.getTextElementAtPosition(x, y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The endpoint scan only knows about arrows, so it happily reaches through
|
||||
// whatever is drawn on top of them. An element stacked above the arrow that
|
||||
// the pointer actually hits owns the click — the text tool should label
|
||||
// that element rather than bind the endpoint hidden behind it.
|
||||
const hitElement = this.app.getElementAtPosition(x, y, {
|
||||
includeLockedElements: true,
|
||||
});
|
||||
|
||||
if (
|
||||
hitElement &&
|
||||
hitElement.id !== endpoint.arrow.id &&
|
||||
this.app.scene.getElementIndex(hitElement.id) >
|
||||
this.app.scene.getElementIndex(endpoint.arrow.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors what `handleTextOnPointerDown` would do at this position, so the
|
||||
* highlight can't promise something the click won't deliver.
|
||||
*/
|
||||
private getAnchorAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): AppState["hoveredArrowTextAnchor"] {
|
||||
const endpoint = this.getBindableEndpointAtPosition(x, y);
|
||||
|
||||
if (endpoint) {
|
||||
return { elementId: endpoint.arrow.id, anchor: endpoint.startOrEnd };
|
||||
}
|
||||
|
||||
const container = this.app.getTextBindableContainerAtPosition(x, y);
|
||||
|
||||
// Only arrows get a midpoint label anchor worth pointing at; other
|
||||
// containers center the text in themselves, which needs no affordance.
|
||||
// An arrow that already has a label is edited in place, not re-anchored.
|
||||
if (
|
||||
!isArrowElement(container) ||
|
||||
getBoundTextElement(container, this.app.scene.getNonDeletedElementsMap())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `getTextBindableContainerAtPosition` resolves an arrow anywhere in its
|
||||
// bounding box, but a click only becomes a *label* when it also snaps to
|
||||
// the arrow's center — off-center clicks drop a free-floating text
|
||||
// instead. Gate on the same check so the highlight can't promise a label
|
||||
// the click won't deliver.
|
||||
const snappedToCenter = this.app.getTextWysiwygSnappedToCenterPosition(
|
||||
x,
|
||||
y,
|
||||
this.app.state,
|
||||
container,
|
||||
);
|
||||
|
||||
return snappedToCenter
|
||||
? { elementId: container.id, anchor: "label" }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a text should be created to read as a label for this endpoint — the
|
||||
* side midpoint to bind, the alignment that pins it, and the scene position
|
||||
* it must sit at. `targetStrokeWidth` is the caller's to provide so it can
|
||||
* guarantee it matches the stroke width the text is then created with — the
|
||||
* binding gap derives from it (see `getTextBindingForArrowEndpoint`).
|
||||
*/
|
||||
getTextBinding(
|
||||
{ arrow, startOrEnd }: ArrowEndpoint,
|
||||
targetStrokeWidth: number,
|
||||
) {
|
||||
return getTextBindingForArrowEndpoint(
|
||||
arrow,
|
||||
startOrEnd,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
targetStrokeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the arrow endpoint to the created text, at the side midpoint the
|
||||
* placement resolved (`getTextBinding`'s `fixedPoint`).
|
||||
*/
|
||||
bindText(
|
||||
{ arrow, startOrEnd }: ArrowEndpoint,
|
||||
text: NonDeleted<ExcalidrawTextElement>,
|
||||
fixedPoint: FixedPoint,
|
||||
): void {
|
||||
bindBindingElementToFixedPoint(
|
||||
arrow,
|
||||
text,
|
||||
startOrEnd,
|
||||
fixedPoint,
|
||||
this.app.scene,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A text bound to an arrow endpoint can't be positioned by the drag — the
|
||||
* binding already placed it — so only its width is dragged out. Returns
|
||||
* whether it owned the drag.
|
||||
*/
|
||||
maybeDragNewText(
|
||||
newElement: ExcalidrawElement,
|
||||
pointerCoords: { x: number; y: number },
|
||||
): boolean {
|
||||
if (
|
||||
!isTextElement(newElement) ||
|
||||
!isEndpointBoundText(
|
||||
newElement,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dragNewTextElement({
|
||||
newElement,
|
||||
...getEndpointBoundTextDragAnchor(newElement),
|
||||
pointerX: pointerCoords.x,
|
||||
zoom: this.app.state.zoom.value,
|
||||
scene: this.app.scene,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the arrow's label is what a grab at this position would pick up:
|
||||
* the pointer is over the label, and no element stacked above the arrow
|
||||
* owns the hit instead.
|
||||
*/
|
||||
isBoundTextGrabbable(
|
||||
element: NonDeletedExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
if (
|
||||
!isArrowElement(element) ||
|
||||
!hitElementBoundText(
|
||||
pointFrom(x, y),
|
||||
element,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hitElements = this.app.getElementsAtPosition(x, y);
|
||||
const arrowIndex = hitElements.findIndex((el) => el.id === element.id);
|
||||
|
||||
return (
|
||||
arrowIndex !== -1 &&
|
||||
!hitElements
|
||||
.slice(arrowIndex + 1)
|
||||
.some((el) => this.app.hitElement(x, y, el, false))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer-move half of dragging a label along its arrow. Owns the move
|
||||
* whenever the gesture started on the label (`pointerDownState.hit.arrowLabel`),
|
||||
* dragging only once past the threshold. Returns whether it owned it.
|
||||
*/
|
||||
maybeDragLabel(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
pointerDownState: PointerDownState,
|
||||
pointerCoords: { x: number; y: number },
|
||||
): boolean {
|
||||
if (!pointerDownState.hit.arrowLabel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.app.cursor.set(CURSOR_TYPE.GRABBING);
|
||||
|
||||
if (
|
||||
linearElementEditor.isDragging ||
|
||||
pointDistance(
|
||||
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
) >=
|
||||
DRAGGING_THRESHOLD / this.app.state.zoom.value
|
||||
) {
|
||||
const updatedEditor = LinearElementEditor.handleBoundTextDragging(
|
||||
linearElementEditor,
|
||||
this.app.scene,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
);
|
||||
if (updatedEditor) {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
this.app.setState({
|
||||
selectedLinearElement: updatedEditor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a double-click on a labeled arrow should start text editing: the
|
||||
* center of the label itself, which a label drag may have moved away from
|
||||
* the arrow's midpoint. Null when the element is not a labeled arrow.
|
||||
*/
|
||||
getLabelCenter(element: ExcalidrawElement): { x: number; y: number } | null {
|
||||
const elementsMap = this.app.scene.getNonDeletedElementsMap();
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (!boundTextElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
x: x + boundTextElement.width / 2,
|
||||
y: y + boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
getSizeFromPoints,
|
||||
isTransparent,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
computeBucketFillPolygon,
|
||||
isBucketFillCompatible,
|
||||
isFrameLikeElement,
|
||||
isRestylableFill,
|
||||
newLinearElement,
|
||||
ShapeCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionChangeBucketFillBackgroundColor } from "../actions";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { activeEyeDropperAtom, type EyeDropperProperties } from "./EyeDropper";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
type ScenePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const BUCKET_FILL_KEYBOARD_COLOR_PICKS = BUCKET_FILL_BACKGROUND_PICKS.filter(
|
||||
(color) => color !== COLOR_PALETTE.white,
|
||||
);
|
||||
|
||||
/** Owns bucket-fill interaction and App-bound scene mutations. */
|
||||
export class AppBucketFill {
|
||||
constructor(private app: App) {}
|
||||
|
||||
private temporaryEyeDropper: EyeDropperProperties | null = null;
|
||||
|
||||
/**
|
||||
* The click armed on pointer down, committed on pointer up. Deferring the
|
||||
* scene mutation to pointer up is what lets a pinch/pan second finger, a
|
||||
* long-press context menu, or a pointercancel abort the fill — on pointer
|
||||
* down the editor cannot yet know the gesture will stay a single-pointer
|
||||
* click.
|
||||
*/
|
||||
private pending: ScenePoint | null = null;
|
||||
|
||||
/** Arm a fill at the pointer-down position; nothing mutates yet. */
|
||||
handlePointerDown = (scenePointer: ScenePoint) => {
|
||||
this.pending = scenePointer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Commit the armed fill, at the pointer-DOWN position (what the user
|
||||
* aimed at). No-op when the gesture was aborted in the meantime.
|
||||
*/
|
||||
handlePointerUp = () => {
|
||||
const scenePointer = this.pending;
|
||||
this.pending = null;
|
||||
if (scenePointer) {
|
||||
this.fill(scenePointer);
|
||||
}
|
||||
};
|
||||
|
||||
/** Abort the armed fill (second finger, context menu, pointercancel). */
|
||||
cancel = () => {
|
||||
this.pending = null;
|
||||
};
|
||||
|
||||
cycleBackgroundColor = () => {
|
||||
const currentColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
const currentIndex = BUCKET_FILL_KEYBOARD_COLOR_PICKS.indexOf(currentColor);
|
||||
const nextColor =
|
||||
BUCKET_FILL_KEYBOARD_COLOR_PICKS[
|
||||
(currentIndex + 1) % BUCKET_FILL_KEYBOARD_COLOR_PICKS.length
|
||||
];
|
||||
|
||||
this.app.actionManager.executeAction(
|
||||
actionChangeBucketFillBackgroundColor,
|
||||
"keyboard",
|
||||
{ currentItemBackgroundColor: nextColor },
|
||||
);
|
||||
};
|
||||
|
||||
openTemporaryEyeDropper = () => {
|
||||
if (editorJotaiStore.get(activeEyeDropperAtom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eyeDropper: EyeDropperProperties = {
|
||||
colorPickerType: "elementBackground",
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: (color) => {
|
||||
this.app.actionManager.executeAction(
|
||||
actionChangeBucketFillBackgroundColor,
|
||||
"ui",
|
||||
{ currentItemBackgroundColor: color },
|
||||
);
|
||||
},
|
||||
};
|
||||
this.temporaryEyeDropper = eyeDropper;
|
||||
this.app.updateEditorAtom(activeEyeDropperAtom, eyeDropper);
|
||||
};
|
||||
|
||||
closeTemporaryEyeDropper = () => {
|
||||
const eyeDropper = this.temporaryEyeDropper;
|
||||
this.temporaryEyeDropper = null;
|
||||
if (
|
||||
eyeDropper &&
|
||||
editorJotaiStore.get(activeEyeDropperAtom) === eyeDropper
|
||||
) {
|
||||
this.app.updateEditorAtom(activeEyeDropperAtom, null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply the current bucket fill settings to an existing fill-compatible
|
||||
* element (no-op when nothing would change). One undoable step.
|
||||
*/
|
||||
private restyle = (element: NonDeletedExcalidrawElement) => {
|
||||
const backgroundColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
if (
|
||||
element.backgroundColor === backgroundColor &&
|
||||
element.fillStyle === this.app.state.currentItemFillStyle &&
|
||||
element.opacity === this.app.state.currentItemOpacity
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.app.scene.mutateElement(element, {
|
||||
backgroundColor,
|
||||
fillStyle: this.app.state.currentItemFillStyle,
|
||||
opacity: this.app.state.currentItemOpacity,
|
||||
});
|
||||
// the roughjs drawable is cached by element reference; in-place
|
||||
// mutation keeps the reference, so evict it or the canvas won't
|
||||
// reflect the new style
|
||||
ShapeCache.delete(element);
|
||||
this.app.scheduleCapture();
|
||||
};
|
||||
|
||||
private fill = (scenePointer: ScenePoint) => {
|
||||
// shared with the generic shape background, but a transparent fill would
|
||||
// be invisible, so fall back to a real color (appState is not mutated)
|
||||
const backgroundColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
|
||||
const elements = this.app.scene.getNonDeletedElements();
|
||||
const elementsMap = this.app.scene.getNonDeletedElementsMap();
|
||||
const point = pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y);
|
||||
|
||||
const hitElement = this.app.getElementAtPosition(point[0], point[1]);
|
||||
|
||||
// gap bridging is covered by the default gapTolerance (a bucket-specific
|
||||
// constant): connector edges close visual gaps without distorting the
|
||||
// shape, so no zoom-dependent tuning is needed
|
||||
const result = computeBucketFillPolygon({ point, elements, elementsMap });
|
||||
|
||||
if (!result.ok) {
|
||||
// no region could be derived, but the click still landed on existing
|
||||
// paint (e.g. an orphaned fill whose boundaries were deleted, or a
|
||||
// hand-drawn strokeless bg polygon on empty canvas) — restyling it is
|
||||
// the sensible outcome, and beats a toast
|
||||
if (hitElement && isBucketFillCompatible(hitElement)) {
|
||||
this.restyle(hitElement);
|
||||
return;
|
||||
}
|
||||
if (result.reason === "too_complex") {
|
||||
this.app.setToast({
|
||||
message: t("bucketfill.tooComplex"),
|
||||
duration: 3000,
|
||||
});
|
||||
} else if (
|
||||
result.reason === "open_region" ||
|
||||
result.reason === "too_small" ||
|
||||
result.reason === "invalid_polygon"
|
||||
) {
|
||||
this.app.setToast({
|
||||
message: t("bucketfill.noRegion"),
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
// "no_owner" stays silent — clicking empty canvas shouldn't nag
|
||||
return;
|
||||
}
|
||||
|
||||
// re-clicking an already-filled region shouldn't stack a duplicate:
|
||||
// restyle the existing fill with the current settings, or no-op when
|
||||
// nothing would change
|
||||
if (
|
||||
hitElement &&
|
||||
isRestylableFill({
|
||||
hitElement,
|
||||
scenePoints: result.scenePoints,
|
||||
elementsMap,
|
||||
})
|
||||
) {
|
||||
this.restyle(hitElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = getSizeFromPoints(result.scenePoints);
|
||||
// linear elements are normalized so points[0] must be [0, 0]; use the
|
||||
// first scene point as the element origin
|
||||
const [originX, originY] = result.scenePoints[0];
|
||||
const points = result.scenePoints.map((p) =>
|
||||
pointFrom<LocalPoint>(p[0] - originX, p[1] - originY),
|
||||
);
|
||||
|
||||
const owner = result.ownerId ? elementsMap.get(result.ownerId) : null;
|
||||
// owner-less fills (regions formed by open lines) take their frame from
|
||||
// the click position and only inherit a group shared by every boundary
|
||||
const frameId = owner
|
||||
? isFrameLikeElement(owner)
|
||||
? owner.id
|
||||
: owner.frameId
|
||||
: this.app.getTopLayerFrameAtSceneCoords(scenePointer)?.id ?? null;
|
||||
const groupIds = owner
|
||||
? owner.groupIds
|
||||
: result.boundaryElementIds
|
||||
.map((id) => elementsMap.get(id)?.groupIds)
|
||||
.filter((groupIds): groupIds is string[] => !!groupIds)
|
||||
.reduce(
|
||||
(common, groupIds) =>
|
||||
common === null
|
||||
? groupIds
|
||||
: common.filter((groupId) => groupIds.includes(groupId)),
|
||||
null as string[] | null,
|
||||
) ?? [];
|
||||
|
||||
const fill = newLinearElement({
|
||||
type: "line",
|
||||
x: originX,
|
||||
y: originY,
|
||||
width,
|
||||
height,
|
||||
points,
|
||||
polygon: true,
|
||||
strokeColor: "transparent",
|
||||
backgroundColor,
|
||||
fillStyle: this.app.state.currentItemFillStyle,
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 0,
|
||||
roundness: null,
|
||||
opacity: this.app.state.currentItemOpacity,
|
||||
frameId,
|
||||
groupIds,
|
||||
// no marker: fills are recognized by shape (`isBucketFillCompatible`),
|
||||
// since metadata would go stale the moment the user restyles the fill
|
||||
});
|
||||
|
||||
// resolve the geometry's relative placement against the
|
||||
// deleted-inclusive array that `insertElementsAtIndex` operates on
|
||||
const anchorIndex = this.app.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.findIndex((el) => el.id === result.insertion.elementId);
|
||||
this.app.scene.insertElementsAtIndex(
|
||||
[fill],
|
||||
anchorIndex < 0
|
||||
? null
|
||||
: result.insertion.placement === "above"
|
||||
? anchorIndex + 1
|
||||
: anchorIndex,
|
||||
);
|
||||
|
||||
// Keep the bucket fill tool active and do NOT select the new fill, so the
|
||||
// user can keep filling regions back-to-back without re-selecting the tool
|
||||
// (even when the tool isn't locked).
|
||||
this.app.scheduleCapture();
|
||||
};
|
||||
|
||||
/**
|
||||
* The color the bucket fill tool actually fills with: the shared
|
||||
* `currentItemBackgroundColor`, falling back to green when that is
|
||||
* transparent (the tool's picker doesn't offer transparent, but the shared
|
||||
* state can hold it from the generic shape picker).
|
||||
*/
|
||||
public getBucketFillBackgroundColor = (
|
||||
backgroundColor: string,
|
||||
/** supply only for display purposes such as when applying to the cursor */
|
||||
theme?: Theme,
|
||||
) =>
|
||||
isTransparent(backgroundColor)
|
||||
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
|
||||
: applyDarkModeFilter(backgroundColor, theme === THEME.DARK);
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
|
||||
|
||||
import { isHandToolActive, isEraserActive } from "../appState";
|
||||
|
||||
import type App from "./App";
|
||||
import { bucketFillIconSvgPaths } from "./icons";
|
||||
|
||||
import type { AppState, DataURL } from "../types";
|
||||
import type App from "./App";
|
||||
|
||||
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
@@ -20,6 +22,19 @@ const laserPointerCursorDataURL_darkMode = `data:${
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
const createBucketFillCursorDataURL = (color: string) => {
|
||||
const paths = bucketFillIconSvgPaths
|
||||
.map(
|
||||
(path, index) =>
|
||||
`<path d="${path}" ${index < 2 ? `fill="${color}"` : ""} />`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" fill="#fff" stroke-width="5">${paths}</g><g stroke="#1b1b1f" stroke-width="1.25">${paths}</g></svg>`,
|
||||
)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures all cursor management for the interactive canvas.
|
||||
*
|
||||
@@ -29,6 +44,13 @@ const laserPointerCursorDataURL_darkMode = `data:${
|
||||
* cursors set here.
|
||||
*/
|
||||
export class AppCursor {
|
||||
private toolCursorMemo: {
|
||||
canvas: HTMLCanvasElement;
|
||||
type: AppState["activeTool"]["type"];
|
||||
bucketFillColor?: string;
|
||||
theme?: AppState["theme"];
|
||||
} | null = null;
|
||||
|
||||
private eraserCanvasCache:
|
||||
| (HTMLCanvasElement & { theme?: AppState["theme"] })
|
||||
| null = null;
|
||||
@@ -41,6 +63,9 @@ export class AppCursor {
|
||||
}
|
||||
|
||||
set = (cursor: string) => {
|
||||
// Hover and interaction cursors override the resting tool cursor. The
|
||||
// next applyForTool() must restore it even if the tool itself is unchanged.
|
||||
this.toolCursorMemo = null;
|
||||
if (this.canvas) {
|
||||
this.canvas.style.cursor = cursor;
|
||||
}
|
||||
@@ -70,15 +95,48 @@ export class AppCursor {
|
||||
applyForTool = (
|
||||
activeTool: AppState["activeTool"] = this.app.state.activeTool,
|
||||
) => {
|
||||
if (!this.canvas) {
|
||||
const canvas = this.canvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const theme =
|
||||
activeTool.type === "eraser" ||
|
||||
activeTool.type === "laser" ||
|
||||
activeTool.type === "bucketfill"
|
||||
? this.app.state.theme
|
||||
: undefined;
|
||||
|
||||
const bucketFillColor =
|
||||
activeTool.type === "bucketfill"
|
||||
? this.app.bucketFill.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
theme,
|
||||
)
|
||||
: undefined;
|
||||
const memo = this.toolCursorMemo;
|
||||
if (
|
||||
memo?.canvas === canvas &&
|
||||
memo.type === activeTool.type &&
|
||||
memo.bucketFillColor === bucketFillColor &&
|
||||
memo.theme === theme
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
this.clear();
|
||||
} else if (isHandToolActive({ activeTool })) {
|
||||
this.set(CURSOR_TYPE.GRAB);
|
||||
} else if (isEraserActive({ activeTool })) {
|
||||
this.applyEraser();
|
||||
} else if (activeTool.type === "autoshape") {
|
||||
this.set(CURSOR_TYPE.CROSSHAIR);
|
||||
} else if (activeTool.type === "bucketfill") {
|
||||
// The hotspot is the center of the paint droplet in the icon.
|
||||
this.set(
|
||||
`url(${createBucketFillCursorDataURL(bucketFillColor!)}) 5 18, auto`,
|
||||
);
|
||||
} else if (activeTool.type === "laser") {
|
||||
const url =
|
||||
this.app.state.theme === THEME.LIGHT
|
||||
@@ -93,13 +151,53 @@ export class AppCursor {
|
||||
} else if (activeTool.type !== "image") {
|
||||
this.set(CURSOR_TYPE.AUTO);
|
||||
}
|
||||
|
||||
this.toolCursorMemo = {
|
||||
canvas,
|
||||
type: activeTool.type,
|
||||
bucketFillColor,
|
||||
theme,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-runs the hover cursor logic at the last known pointer position by
|
||||
* re-dispatching a synthetic pointermove — for state changes that alter
|
||||
* what hovering means without the pointer moving (e.g. the tool reverting
|
||||
* to selection after an element is created). Mouse only: touch and pen
|
||||
* have no resting hover to restore.
|
||||
*/
|
||||
refreshHover = () => {
|
||||
const lastEvent =
|
||||
this.app.lastPointerMoveEvent ?? this.app.lastPointerUpEvent;
|
||||
|
||||
if (!lastEvent || !this.canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerType = (lastEvent as PointerEvent).pointerType;
|
||||
if (pointerType && pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
|
||||
// jsdom has no PointerEvent; a MouseEvent of type "pointermove" walks
|
||||
// the identical dispatch path
|
||||
const EventConstructor =
|
||||
typeof PointerEvent !== "undefined" ? PointerEvent : MouseEvent;
|
||||
|
||||
this.canvas.dispatchEvent(
|
||||
new EventConstructor("pointermove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: lastEvent.clientX,
|
||||
clientY: lastEvent.clientY,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** clears the inline cursor so the environment default (CSS) applies */
|
||||
private clear = () => {
|
||||
if (this.canvas) {
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
this.set("");
|
||||
};
|
||||
|
||||
private applyEraser = () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { randomInteger } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
bindBindingElement,
|
||||
convertToShape,
|
||||
convertToShapeHandlePointerMoveFromPointerDown,
|
||||
getHoveredElementForBinding,
|
||||
isBindingElement,
|
||||
isLineElement,
|
||||
LinearElementEditor,
|
||||
maxBindingDistance_simple,
|
||||
newArrowElement,
|
||||
updateBoundPoint,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointsPositionUpdates,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionFinalize } from "../actions";
|
||||
import { DrawShapeTrail } from "../drawShapeTrail";
|
||||
|
||||
import type App from "./App";
|
||||
import type { PointerDownState } from "../types";
|
||||
|
||||
/**
|
||||
* Captures the App state management for the drawShape tool (free-form
|
||||
* sketch converted into a recognized excalidraw element). Pointer events
|
||||
* are piped here from App.
|
||||
*
|
||||
* The gesture is committed by `finalize()` — recognize → (maybe upgrade
|
||||
* line to arrow) → insert → bind endpoints — which is invoked from within
|
||||
* `actionFinalize`, the editor's single finalization funnel. Whatever
|
||||
* triggers a finalize (the gesture's own pointerup, a mid-gesture paste
|
||||
* switching tools, …), the pending sketch resolves there exactly once.
|
||||
*/
|
||||
export class AppDrawShape {
|
||||
public trail: DrawShapeTrail;
|
||||
|
||||
/** a pointerdown started a sketch that `finalize()` hasn't resolved yet */
|
||||
private gestureInProgress = false;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.trail = new DrawShapeTrail(app);
|
||||
}
|
||||
|
||||
stop = () => {
|
||||
this.trail.stop();
|
||||
};
|
||||
|
||||
hasPendingGesture = () => {
|
||||
return this.gestureInProgress;
|
||||
};
|
||||
|
||||
handlePointerDown = (pointerDownState: PointerDownState) => {
|
||||
if (this.gestureInProgress) {
|
||||
// a stale gesture (e.g. after a missed pointerup) — resolve it before
|
||||
// starting a new one
|
||||
this.app.actionManager.executeAction(actionFinalize);
|
||||
}
|
||||
this.gestureInProgress = true;
|
||||
this.trail.startPath(
|
||||
pointerDownState.lastCoords.x,
|
||||
pointerDownState.lastCoords.y,
|
||||
);
|
||||
};
|
||||
|
||||
/** returns true when the pointermove was consumed by the shape preview */
|
||||
handlePointerMove = (pointerCoords: { x: number; y: number }): boolean => {
|
||||
return convertToShapeHandlePointerMoveFromPointerDown(
|
||||
this.app,
|
||||
pointerCoords,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Binds both endpoints of a freshly inserted drawShape arrow to the
|
||||
* bindable elements they hover, if any.
|
||||
*
|
||||
* Endpoints bind in "orbit" mode — the arrow starts/ends at the target's
|
||||
* outline — even when the stroke was started or released inside the shape
|
||||
* (a sketched connector aims at the shape, not at a point inside it). The
|
||||
* exception is a sketch contained in a single shape: both ends then bind
|
||||
* "inside" and stay where they were drawn, like the interactive
|
||||
* inside→inside flow.
|
||||
*/
|
||||
private bindRecognizedArrow = (arrow: NonDeleted<ExcalidrawArrowElement>) => {
|
||||
const { app } = this;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
|
||||
|
||||
const endpoints = (["start", "end"] as const).map((startOrEnd) => {
|
||||
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
return {
|
||||
startOrEnd,
|
||||
globalPoint,
|
||||
target: getHoveredElementForBinding(
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
bindingDistance,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const sameTarget =
|
||||
!!endpoints[0].target && endpoints[0].target === endpoints[1].target;
|
||||
const isMidpointSnappingEnabled =
|
||||
app.state.isMidpointSnappingEnabled && !app.state.gridModeEnabled;
|
||||
|
||||
for (const { startOrEnd, globalPoint, target } of endpoints) {
|
||||
if (target) {
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
target,
|
||||
sameTarget ? "inside" : "orbit",
|
||||
startOrEnd,
|
||||
app.scene,
|
||||
globalPoint,
|
||||
app.state.isBindingEnabled,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sameTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pull the orbit-bound endpoints onto the target outlines; both bindings
|
||||
// exist by now, so each endpoint clips the segment between the two focus
|
||||
// points (mirrors the focusPoint branch of bindOrUnbindBindingElement)
|
||||
const updates: PointsPositionUpdates = new Map();
|
||||
for (const { startOrEnd, target } of endpoints) {
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const point = updateBoundPoint(
|
||||
arrow,
|
||||
startOrEnd === "start" ? "startBinding" : "endBinding",
|
||||
startOrEnd === "start" ? arrow.startBinding : arrow.endBinding,
|
||||
target,
|
||||
elementsMap,
|
||||
);
|
||||
if (point) {
|
||||
updates.set(startOrEnd === "start" ? 0 : arrow.points.length - 1, {
|
||||
point,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updates.size) {
|
||||
LinearElementEditor.movePoints(arrow, app.scene, updates);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A sketched line that connects a bindable element with something else —
|
||||
* another element or blank canvas — was meant as a connector: upgrade it
|
||||
* to an arrow (called before insertion). A line touching no shape, or
|
||||
* contained in a single shape (an annotation, not a connector), stays a
|
||||
* line.
|
||||
*/
|
||||
private maybeUpgradeLineToArrow = (
|
||||
line: NonDeleted<ExcalidrawLineElement>,
|
||||
): NonDeleted<ExcalidrawArrowElement> | null => {
|
||||
const { app } = this;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
|
||||
|
||||
const [startTarget, endTarget] = ([0, -1] as const).map((index) =>
|
||||
getHoveredElementForBinding(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
line,
|
||||
index,
|
||||
elementsMap,
|
||||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
bindingDistance,
|
||||
),
|
||||
);
|
||||
if ((!startTarget && !endTarget) || startTarget === endTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return newArrowElement({
|
||||
type: "arrow",
|
||||
x: line.x,
|
||||
y: line.y,
|
||||
width: line.width,
|
||||
height: line.height,
|
||||
startArrowhead: null,
|
||||
endArrowhead: app.state.currentItemEndArrowhead,
|
||||
points: line.points,
|
||||
groupIds: line.groupIds,
|
||||
frameId: line.frameId,
|
||||
locked: false,
|
||||
angle: line.angle,
|
||||
strokeColor: line.strokeColor,
|
||||
backgroundColor: line.backgroundColor,
|
||||
fillStyle: line.fillStyle,
|
||||
roughness: line.roughness,
|
||||
opacity: line.opacity,
|
||||
strokeStyle: line.strokeStyle,
|
||||
strokeWidth: line.strokeWidth,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Commits the pending sketch, if any, and clears the trail. Invoked from
|
||||
* within `actionFinalize` — never call directly, execute `actionFinalize`
|
||||
* instead so element insertion flows into the action's returned elements.
|
||||
*/
|
||||
finalize = () => {
|
||||
const { app } = this;
|
||||
|
||||
// when no gesture is in progress this only clears residual trail state
|
||||
const points = this.gestureInProgress ? this.trail.getCurrentPoints() : [];
|
||||
this.gestureInProgress = false;
|
||||
this.trail.endPath();
|
||||
|
||||
// note: size gate inside recognizeShape
|
||||
if (points.length >= 3) {
|
||||
const detectedElement =
|
||||
app.state.newElement ||
|
||||
convertToShape(
|
||||
points,
|
||||
app.state,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.state.newElement,
|
||||
app.scene.getNonDeletedFramesLikes(),
|
||||
);
|
||||
|
||||
if (detectedElement) {
|
||||
let element: NonDeletedExcalidrawElement = {
|
||||
...detectedElement,
|
||||
seed: randomInteger(),
|
||||
opacity: app.state.currentItemOpacity,
|
||||
};
|
||||
|
||||
if (app.state.isBindingEnabled && isLineElement(element)) {
|
||||
element = this.maybeUpgradeLineToArrow(element) ?? element;
|
||||
}
|
||||
|
||||
app.insertNewElement(element);
|
||||
|
||||
if (app.state.isBindingEnabled && isBindingElement(element)) {
|
||||
this.bindRecognizedArrow(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.trail.clearTrails();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
DRAGGING_THRESHOLD,
|
||||
EVENT,
|
||||
KEYS,
|
||||
getGridPoint,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { isStickyNoteElement, newStickyNoteElement } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawNonSelectionElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
import type App from "./App";
|
||||
import type { ToolType } from "../types";
|
||||
|
||||
type ScenePoint = { x: number; y: number };
|
||||
|
||||
/** what a drag places: any element the new-element canvas can preview */
|
||||
type DraggedElement = NonDeleted<ExcalidrawNonSelectionElement>;
|
||||
|
||||
/**
|
||||
* A tool the user can drag out of the toolbar to drop a default-sized element
|
||||
* where the pointer is released.
|
||||
*/
|
||||
type DraggableTool = {
|
||||
/** the element the drop creates, centered on the pointer */
|
||||
createElement: (app: App, center: ScenePoint) => DraggedElement;
|
||||
/** what the tool does once its element is on the canvas (e.g. start editing) */
|
||||
onDrop?: (app: App, element: DraggedElement) => void;
|
||||
};
|
||||
|
||||
const centered = (center: ScenePoint, width: number, height: number) => ({
|
||||
x: center.x - width / 2,
|
||||
y: center.y - height / 2,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
/**
|
||||
* The tools that can be dragged out of the toolbar. Add an entry to make a
|
||||
* tool's button draggable — the gesture, preview and drop are shared.
|
||||
*/
|
||||
export const DRAGGABLE_TOOLS: Partial<Record<ToolType, DraggableTool>> = {
|
||||
stickynote: {
|
||||
createElement: (app, center) => {
|
||||
const size = DEFAULT_STICKY_NOTE_SIZE;
|
||||
const { state } = app;
|
||||
return newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
...centered(center, size, size),
|
||||
baseHeight: size,
|
||||
strokeColor: state.currentItemStickynoteStrokeColor,
|
||||
backgroundColor: state.currentItemStickynoteBackgroundColor,
|
||||
fillStyle: state.currentItemFillStyle,
|
||||
strokeWidth: app.getCurrentItemStrokeWidth("stickynote"),
|
||||
strokeStyle: state.currentItemStrokeStyle,
|
||||
roughness: state.currentItemRoughness,
|
||||
opacity: state.currentItemOpacity,
|
||||
roundness: app.getCurrentItemRoundness("stickynote"),
|
||||
locked: false,
|
||||
frameId: app.getTopLayerFrameAtSceneCoords(center)?.id ?? null,
|
||||
});
|
||||
},
|
||||
onDrop: (app, element) => {
|
||||
if (isStickyNoteElement(element)) {
|
||||
app.startTextEditing({
|
||||
sceneX: element.x + element.width / 2,
|
||||
sceneY: element.y + element.height / 2,
|
||||
container: element,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** CSS opacity of the preview canvas while dragging */
|
||||
export const TOOL_DRAG_PREVIEW_OPACITY = 0.5;
|
||||
|
||||
/**
|
||||
* Dragging a tool out of the toolbar. The preview element exists only here
|
||||
* until the drop — it is not in the scene, the store or the history, so
|
||||
* collaborators never see it and an aborted drag leaves nothing behind. It
|
||||
* is painted by the `NewElementCanvas`, drawn exactly as it will land, with
|
||||
* the canvas itself made translucent.
|
||||
*
|
||||
* The button arms on pointerdown; the drag starts once the pointer has
|
||||
* moved `DRAGGING_THRESHOLD`, so a plain click still selects the tool. The
|
||||
* gesture lives on the owner window (the pointer leaves the button at once),
|
||||
* like the other pointer gestures in `App`.
|
||||
*/
|
||||
export class AppToolDrag {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/** the element under the pointer while dragging, `null` otherwise */
|
||||
public preview: DraggedElement | null = null;
|
||||
|
||||
private armed: {
|
||||
type: ToolType;
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
} | null = null;
|
||||
|
||||
private tool: DraggableTool | null = null;
|
||||
private type: ToolType | null = null;
|
||||
|
||||
static isDraggable = (type: ToolType) => type in DRAGGABLE_TOOLS;
|
||||
|
||||
isDragging = () => this.preview !== null;
|
||||
|
||||
/**
|
||||
* Arm a drag from a tool button's pointerdown. Returns whether the tool is
|
||||
* draggable (the caller lets a non-draggable press fall through).
|
||||
*/
|
||||
handleButtonPointerDown = (type: ToolType, event: PointerEvent) => {
|
||||
const tool = DRAGGABLE_TOOLS[type];
|
||||
if (!tool || event.button !== 0 || !this.app.isInteractionEnabled()) {
|
||||
return false;
|
||||
}
|
||||
this.cancel();
|
||||
this.armed = {
|
||||
type,
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
};
|
||||
this.tool = tool;
|
||||
this.type = type;
|
||||
this.addListeners();
|
||||
return true;
|
||||
};
|
||||
|
||||
private onPointerMove = (event: PointerEvent) => {
|
||||
if (!this.armed || event.pointerId !== this.armed.pointerId) {
|
||||
return;
|
||||
}
|
||||
if (!this.preview) {
|
||||
const moved = Math.hypot(
|
||||
event.clientX - this.armed.clientX,
|
||||
event.clientY - this.armed.clientY,
|
||||
);
|
||||
if (moved < DRAGGING_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
trackEvent("toolbar", this.armed.type, "drag");
|
||||
}
|
||||
const preview = this.tool!.createElement(
|
||||
this.app,
|
||||
viewportCoordsToSceneCoords(event, this.app.state),
|
||||
);
|
||||
// Snap after the tool centers its element, just like click-to-place.
|
||||
const [x, y] = getGridPoint(
|
||||
preview.x,
|
||||
preview.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.app.getEffectiveGridSize(),
|
||||
);
|
||||
this.preview = { ...preview, x, y };
|
||||
this.app.triggerRender();
|
||||
};
|
||||
|
||||
private onPointerUp = (event: PointerEvent) => {
|
||||
if (!this.armed || event.pointerId !== this.armed.pointerId) {
|
||||
return;
|
||||
}
|
||||
const { preview, tool, type } = this;
|
||||
// the button's own click handles a press that never became a drag
|
||||
if (!preview) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
this.cancel();
|
||||
if (!this.isOverCanvas(event)) {
|
||||
return;
|
||||
}
|
||||
const { app } = this;
|
||||
app.insertNewElement(preview);
|
||||
app.store.scheduleCapture();
|
||||
app.setState({
|
||||
selectedElementIds: { [preview.id]: true },
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
// the drop is a one-off placement: whatever tool was active (an arrow,
|
||||
// say) must not stay armed under the dropped element or its editor
|
||||
app.setActiveTool(
|
||||
{ type: app.state.preferredSelectionTool.type },
|
||||
{ keepSelection: true },
|
||||
);
|
||||
trackEvent("toolbar", type!, "drop");
|
||||
tool!.onDrop?.(app, preview);
|
||||
};
|
||||
|
||||
private onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.stopPropagation();
|
||||
this.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
/** drop the gesture without a trace */
|
||||
cancel = () => {
|
||||
const wasDragging = this.preview !== null;
|
||||
this.armed = null;
|
||||
this.tool = null;
|
||||
this.type = null;
|
||||
this.preview = null;
|
||||
this.removeListeners();
|
||||
if (wasDragging) {
|
||||
this.app.triggerRender();
|
||||
}
|
||||
};
|
||||
|
||||
private isOverCanvas = (event: PointerEvent) => {
|
||||
// the element under the pointer. `elementFromPoint` sees through the
|
||||
// implicit capture a touch gives the button; jsdom doesn't implement it,
|
||||
// so fall back to the event's target
|
||||
const target = (this.app.ownerDocument.elementFromPoint?.(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
) ?? event.target) as Element | null;
|
||||
return !!target?.closest?.(".excalidraw__canvas");
|
||||
};
|
||||
|
||||
private addListeners = () => {
|
||||
const { ownerWindow } = this.app;
|
||||
ownerWindow.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
ownerWindow.addEventListener(EVENT.POINTER_UP, this.onPointerUp);
|
||||
ownerWindow.addEventListener(EVENT.POINTER_CANCEL, this.cancel);
|
||||
ownerWindow.addEventListener(EVENT.BLUR, this.cancel);
|
||||
ownerWindow.addEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
|
||||
};
|
||||
|
||||
private removeListeners = () => {
|
||||
const { ownerWindow } = this.app;
|
||||
ownerWindow.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
ownerWindow.removeEventListener(EVENT.POINTER_UP, this.onPointerUp);
|
||||
ownerWindow.removeEventListener(EVENT.POINTER_CANCEL, this.cancel);
|
||||
ownerWindow.removeEventListener(EVENT.BLUR, this.cancel);
|
||||
ownerWindow.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
|
||||
};
|
||||
}
|
||||
+1171
-655
File diff suppressed because it is too large
Load Diff
@@ -744,29 +744,40 @@ export class AppViewport {
|
||||
return false;
|
||||
}
|
||||
|
||||
// a user gesture interrupting an animated `setViewport` takes over from
|
||||
// frames that draw from zoom-scaled bitmaps, and the completion handler
|
||||
// that would switch back to crisp ones no longer runs. Otherwise the
|
||||
// flag is left alone: wheel and touch zoom set it and clear it on a
|
||||
// debounce once the gesture is over, and a pan frame landing between two
|
||||
// zoom ticks must not force every element to be re-rasterized at the
|
||||
// in-progress zoom
|
||||
if (this.activeTransition) {
|
||||
this.app.setState({ shouldCacheIgnoreZoom: false });
|
||||
}
|
||||
this.cancelTransition();
|
||||
if (!opts?.preserveScrollConstraintsSnapBack) {
|
||||
AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
|
||||
}
|
||||
this.app.setState({ shouldCacheIgnoreZoom: false });
|
||||
if (this.app.state.userToFollow) {
|
||||
this.app.setState({ userToFollow: null });
|
||||
}
|
||||
this.app.requestUnfollow();
|
||||
|
||||
const prevZoom = this.app.state.zoom.value;
|
||||
this.app.setState(state);
|
||||
|
||||
this.app.setState((prevState) => {
|
||||
if (!prevState.scrollConstraints) {
|
||||
this.app.setState((prevState, props) => {
|
||||
const update =
|
||||
typeof state === "function" ? state(prevState, props) : state;
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
const nextState = { ...prevState, ...update };
|
||||
if (!nextState.scrollConstraints) {
|
||||
return update;
|
||||
}
|
||||
const zoomed =
|
||||
!opts?.zoomPreConstrained && prevState.zoom.value !== prevZoom;
|
||||
const overscroll = zoomed ? 0 : prevState.scrollConstraints.overscroll;
|
||||
!opts?.zoomPreConstrained &&
|
||||
nextState.zoom.value !== prevState.zoom.value;
|
||||
const overscroll = zoomed ? 0 : nextState.scrollConstraints.overscroll;
|
||||
if (overscroll > 0) {
|
||||
this.snapBackDebounced();
|
||||
}
|
||||
return constrainScrollState(prevState, overscroll);
|
||||
return { ...nextState, ...constrainScrollState(nextState, overscroll) };
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { CLASSES, KEYS, MIN_ZOOM, ZOOM_STEP } from "@excalidraw/common";
|
||||
|
||||
import { resolveInputDevice } from "../appState";
|
||||
import { withBatchedUpdates } from "../reactUtils";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { getViewportForZoomWithScrollConstraints } from "../viewport";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
/** `MouseEvent.buttons` bit of the wheel (middle) button */
|
||||
const WHEEL_BUTTON_MASK = 4;
|
||||
|
||||
/**
|
||||
* Wheel input over the editor: pans the canvas, or zooms it around the
|
||||
* pointer (`viewport.lastPosition`) — on ctrl/cmd+wheel (which is also how
|
||||
* a trackpad pinch is delivered), while the wheel button itself is held
|
||||
* down, or on a plain wheel when the input device is a mouse
|
||||
* (`appState.inputDevice`; ctrl/cmd+wheel pans instead then).
|
||||
*/
|
||||
export class AppWheel {
|
||||
constructor(
|
||||
private app: App,
|
||||
private dependencies: {
|
||||
/** whether a drag-pan (wheel button, space+drag, hand tool) is in
|
||||
* progress — wheel input is ignored meanwhile */
|
||||
isPanning: () => boolean;
|
||||
/** applies the pointer movement the drag-pan is holding back for its
|
||||
* next frame, if any */
|
||||
flushPanMove: () => void;
|
||||
},
|
||||
) {}
|
||||
|
||||
/** the editor surfaces whose wheel input the editor consumes; everywhere
|
||||
* else (menus, sidebars, …) the DOM keeps scrolling. The frame-name labels
|
||||
* are DOM, but sit inside the container this listener is attached to */
|
||||
private isOverEditorSurface = (event: WheelEvent) => {
|
||||
const { ownerWindow } = this.app;
|
||||
return (
|
||||
event.target instanceof ownerWindow.HTMLCanvasElement ||
|
||||
event.target instanceof ownerWindow.HTMLTextAreaElement ||
|
||||
event.target instanceof ownerWindow.HTMLIFrameElement ||
|
||||
(event.target instanceof ownerWindow.HTMLElement &&
|
||||
event.target.classList.contains(CLASSES.FRAME_NAME))
|
||||
);
|
||||
};
|
||||
|
||||
handle = withBatchedUpdates((event: WheelEvent) => {
|
||||
// NOTE no preventDefault so the page can scroll over the editor
|
||||
if (!this.app.isNavigationEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!this.isOverEditorSurface(event)) {
|
||||
// prevent zooming the browser (but allow scrolling DOM)
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// scrolling with the wheel button held down zooms: the wheel sits under
|
||||
// the same finger, so a wheel-button pan can be zoomed without reaching
|
||||
// for a modifier — hence it wins over any modifier, and over the
|
||||
// drag-pan the button itself started
|
||||
const isWheelButtonHeld = !!(event.buttons & WHEEL_BUTTON_MASK);
|
||||
|
||||
if (this.dependencies.isPanning()) {
|
||||
if (!isWheelButtonHeld) {
|
||||
return;
|
||||
}
|
||||
// the drag-pan applies pointer movement once per frame; a move still
|
||||
// waiting for its frame has to land before the zoom, or it lands after
|
||||
// it — at the new zoom, on a viewport the zoom anchored without it —
|
||||
// and the point grabbed by the pan drifts from under the cursor a
|
||||
// little on every tick
|
||||
this.dependencies.flushPanMove();
|
||||
}
|
||||
|
||||
const { deltaX, deltaY } = event;
|
||||
if (!deltaX && !deltaY) {
|
||||
return;
|
||||
}
|
||||
// note that event.ctrlKey is necessary to handle pinch zooming
|
||||
const hasZoomModifier = event.metaKey || event.ctrlKey;
|
||||
const shouldZoom =
|
||||
// a horizontal-only wheel (tilt wheel, sideways two-finger scroll)
|
||||
// has nothing to zoom by; it pans sideways below instead
|
||||
deltaY !== 0 &&
|
||||
(isWheelButtonHeld ||
|
||||
(resolveInputDevice(this.app.state.inputDevice) === "mouse"
|
||||
? // a mouse has no pinch: a plain wheel zooms, and any modifier
|
||||
// pans instead (ctrl/cmd vertically, shift horizontally)
|
||||
!hasZoomModifier && !event.shiftKey
|
||||
: hasZoomModifier));
|
||||
if (shouldZoom) {
|
||||
this.zoomBy(deltaY);
|
||||
return;
|
||||
}
|
||||
|
||||
// scroll horizontally when shift pressed
|
||||
if (event.shiftKey) {
|
||||
this.app.viewport.translate(({ zoom, scrollX }) => ({
|
||||
// on Mac, shift+wheel tends to result in deltaX
|
||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.app.viewport.translate(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: scrollX - deltaX / zoom.value,
|
||||
scrollY: scrollY - deltaY / zoom.value,
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevents the browser's own zoom over the non-interactive editor while
|
||||
* letting regular scroll through (trackpad pinch is delivered as
|
||||
* ctrl+wheel).
|
||||
*/
|
||||
preventBrowserZoom = (event: WheelEvent) => {
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/** zooms around the pointer by a wheel delta (positive = zoom out) */
|
||||
private zoomBy = (deltaY: number) => {
|
||||
const { viewport } = this.app;
|
||||
|
||||
const sign = Math.sign(deltaY);
|
||||
const MAX_STEP = ZOOM_STEP * 100;
|
||||
const absDelta = Math.abs(deltaY);
|
||||
let delta = deltaY;
|
||||
if (absDelta > MAX_STEP) {
|
||||
delta = MAX_STEP * sign;
|
||||
}
|
||||
|
||||
// where the pointer is now — the updater below runs when React flushes,
|
||||
// by which time the pointer may have moved on (`lastPosition` is live)
|
||||
const { x: viewportX, y: viewportY } = viewport.lastPosition;
|
||||
|
||||
const didTranslate = viewport.translate(
|
||||
// computed from the state the zoom applies to, not `app.state`: wheel
|
||||
// ticks arriving before React has flushed the previous one would
|
||||
// otherwise all start from the same zoom and collapse into one step
|
||||
(state) => {
|
||||
let newZoom = state.zoom.value - delta / 100;
|
||||
// increase zoom steps the more zoomed-in we are (applies to >100% only)
|
||||
newZoom +=
|
||||
Math.log10(Math.max(1, state.zoom.value)) *
|
||||
-sign *
|
||||
// reduced amplification for small deltas (small movements on a trackpad)
|
||||
Math.min(1, absDelta / 20);
|
||||
|
||||
const minZoom = state.scrollConstraints?.lockZoom
|
||||
? state.scrollConstraints.zoom
|
||||
: MIN_ZOOM;
|
||||
const nextZoom = getNormalizedZoom(Math.max(newZoom, minZoom));
|
||||
if (nextZoom === state.zoom.value) {
|
||||
// at a zoom limit there is nothing to do; flipping the bitmap-cache
|
||||
// flag would only redraw the scene on every tick
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
{ viewportX, viewportY, nextZoom },
|
||||
state,
|
||||
),
|
||||
shouldCacheIgnoreZoom: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
zoomPreConstrained: true,
|
||||
preserveScrollConstraintsSnapBack: true,
|
||||
},
|
||||
);
|
||||
if (didTranslate) {
|
||||
this.app.resetShouldCacheIgnoreZoomDebounced();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const ColorInput = ({
|
||||
}) => {
|
||||
const editorInterface = useEditorInterface();
|
||||
const [innerValue, setInnerValue] = useState(color);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [activeSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
@@ -39,11 +40,18 @@ export const ColorInput = ({
|
||||
|
||||
const changeColor = useCallback(
|
||||
(inputValue: string) => {
|
||||
const value = inputValue.toLowerCase();
|
||||
const value = inputValue.toLowerCase().trim();
|
||||
const color = normalizeInputColor(value);
|
||||
|
||||
if (color) {
|
||||
onChange(color);
|
||||
setErrorMessage(null);
|
||||
} else if (value.length === 0) {
|
||||
setErrorMessage(null);
|
||||
} else if (/^#?[0-9a-f]+$/.test(value)) {
|
||||
setErrorMessage(t("colorPicker.invalidHexLength"));
|
||||
} else {
|
||||
setErrorMessage(t("colorPicker.invalidColor"));
|
||||
}
|
||||
setInnerValue(value);
|
||||
},
|
||||
@@ -68,66 +76,79 @@ export const ColorInput = ({
|
||||
}, [setEyeDropperState]);
|
||||
|
||||
return (
|
||||
<div className="color-picker__input-label">
|
||||
<div className="color-picker__input-hash">#</div>
|
||||
<input
|
||||
ref={activeSection === "hex" ? inputRef : undefined}
|
||||
style={{ border: 0, padding: 0 }}
|
||||
spellCheck={false}
|
||||
className="color-picker-input"
|
||||
aria-label={label}
|
||||
onChange={(event) => {
|
||||
changeColor(event.target.value);
|
||||
}}
|
||||
value={(innerValue || "").replace(/^#/, "")}
|
||||
onBlur={() => {
|
||||
setInnerValue(color);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onFocus={() => setActiveColorPickerSection("hex")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
return;
|
||||
} else if (event.key === KEYS.ESCAPE) {
|
||||
eyeDropperTriggerRef.current?.focus();
|
||||
}
|
||||
event.stopPropagation();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{/* TODO reenable on mobile with a better UX */}
|
||||
{editorInterface.formFactor !== "phone" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: "1px",
|
||||
height: "1.25rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={eyeDropperTriggerRef}
|
||||
className={clsx("excalidraw-eye-dropper-trigger", {
|
||||
selected: eyeDropperState,
|
||||
})}
|
||||
onClick={() =>
|
||||
setEyeDropperState((s) =>
|
||||
s
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: (color) => onChange(color),
|
||||
colorPickerType,
|
||||
},
|
||||
)
|
||||
<div className="color-picker__input-label-container">
|
||||
<div
|
||||
className={clsx("color-picker__input-label", {
|
||||
"has-error": errorMessage,
|
||||
})}
|
||||
>
|
||||
<div className="color-picker__input-hash">#</div>
|
||||
<input
|
||||
ref={activeSection === "hex" ? inputRef : undefined}
|
||||
style={{ border: 0, padding: 0 }}
|
||||
spellCheck={false}
|
||||
className="color-picker-input"
|
||||
aria-label={label}
|
||||
aria-invalid={!!errorMessage}
|
||||
onChange={(event) => {
|
||||
changeColor(event.target.value);
|
||||
}}
|
||||
value={(innerValue || "").replace(/^#/, "")}
|
||||
onBlur={() => {
|
||||
setInnerValue(color);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onFocus={() => setActiveColorPickerSection("hex")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
return;
|
||||
} else if (event.key === KEYS.ESCAPE) {
|
||||
eyeDropperTriggerRef.current?.focus();
|
||||
}
|
||||
title={`${t(
|
||||
"labels.eyeDropper",
|
||||
)} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
|
||||
>
|
||||
{eyeDropperIcon}
|
||||
</div>
|
||||
</>
|
||||
event.stopPropagation();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{/* TODO reenable on mobile with a better UX */}
|
||||
{editorInterface.formFactor !== "phone" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: "1px",
|
||||
height: "1.25rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={eyeDropperTriggerRef}
|
||||
className={clsx("excalidraw-eye-dropper-trigger", {
|
||||
selected: eyeDropperState,
|
||||
})}
|
||||
onClick={() =>
|
||||
setEyeDropperState((s) =>
|
||||
s
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: (color) => onChange(color),
|
||||
colorPickerType,
|
||||
},
|
||||
)
|
||||
}
|
||||
title={`${t(
|
||||
"labels.eyeDropper",
|
||||
)} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
|
||||
>
|
||||
{eyeDropperIcon}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="color-picker__error-message" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@use "../../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
--color-outline: var(--color-gray-20);
|
||||
&.theme--dark {
|
||||
--color-outline: var(--color-gray-80);
|
||||
}
|
||||
|
||||
.focus-visible-none {
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
@@ -20,6 +25,44 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.color-picker__context-menu {
|
||||
background: var(--popup-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.color-picker__context-menu-item {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-primary-color);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
// :hover as well — radix only sets data-highlighted once the item gets
|
||||
// focus via pointermove, which leaves dead frames (e.g. menu opening
|
||||
// under a stationary cursor)
|
||||
&[data-highlighted],
|
||||
&:not([data-disabled]):hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__tip {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
.color-picker-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20px 1.625rem;
|
||||
@@ -48,7 +91,7 @@
|
||||
--size: 1.375rem;
|
||||
|
||||
&.has-outline {
|
||||
box-shadow: inset 0 0 0 1px #d9d9d9;
|
||||
box-shadow: 0 0 0 1px var(--color-outline);
|
||||
}
|
||||
|
||||
padding: 0;
|
||||
@@ -57,28 +100,22 @@
|
||||
height: var(--size);
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
background-color: var(--swatch-color);
|
||||
background-position: left center;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover:not(.active):not(.color-picker__button--large) {
|
||||
transform: scale(1.075);
|
||||
}
|
||||
|
||||
&:hover:not(.active).color-picker__button--large {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
box-shadow: 0 0 0 1px var(--color-gray-30);
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-primary) !important;
|
||||
}
|
||||
&:active.color-picker__button--large {
|
||||
box-shadow: none !important;
|
||||
&.has-outline {
|
||||
box-shadow: 0 0 0 1px var(--color-outline) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +140,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&.active:not(:active) {
|
||||
.color-picker__button-outline {
|
||||
position: absolute;
|
||||
--offset: -1px;
|
||||
@@ -114,7 +151,6 @@
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
z-index: 1; // due hover state so this has preference
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +176,7 @@
|
||||
}
|
||||
|
||||
&--large {
|
||||
--radius: 0.5rem;
|
||||
--radius: 6px;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
}
|
||||
@@ -177,10 +213,10 @@
|
||||
|
||||
.color-picker__button__hotkey-label {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 3px;
|
||||
filter: none;
|
||||
font-size: 11px;
|
||||
right: 4px;
|
||||
bottom: 2px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -288,10 +324,6 @@
|
||||
|
||||
&-colors {
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.color-picker-swatch {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,41 +331,6 @@
|
||||
grid-column: 1 / span 5;
|
||||
}
|
||||
|
||||
.color-picker-swatch {
|
||||
position: relative;
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
background-color: currentColor !important;
|
||||
filter: var(--theme-filter);
|
||||
|
||||
&:focus {
|
||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||
box-shadow: 0 0 4px 1px currentColor;
|
||||
border-color: var(--select-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-transparent {
|
||||
border-radius: 4px;
|
||||
box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.color-picker-transparent,
|
||||
.color-picker-label-swatch {
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
}
|
||||
|
||||
.color-picker-hash {
|
||||
height: var(--default-button-size);
|
||||
flex-shrink: 0;
|
||||
@@ -368,6 +365,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__input-label-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.color-picker__input-label {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
@@ -383,12 +386,28 @@
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
border-color: var(--color-danger, #e03131);
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-danger, #e03131);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__input-hash {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.color-picker__error-message {
|
||||
color: var(--color-danger, #e03131);
|
||||
font-size: 0.75rem;
|
||||
padding: 0 8px 4px 8px;
|
||||
text-align: left;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -423,74 +442,157 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-label-swatch-container {
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
// top-picks drag & drop (pinning + reordering)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.color-picker-label-swatch {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--swatch-color) !important;
|
||||
overflow: hidden;
|
||||
.color-picker__top-picks {
|
||||
// anchor for the animated drop-target outline
|
||||
position: relative;
|
||||
filter: var(--theme-filter);
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
.color-picker__top-picks .color-picker__button {
|
||||
// the strip is tiny and never scrolls — allow touch-dragging picks
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.color-picker__top-picks.is-dnd-active {
|
||||
.color-picker__button {
|
||||
// no hover/tooltips while a drag is in flight; hit-testing is
|
||||
// coordinate-based so this doesn't affect dropping
|
||||
pointer-events: none;
|
||||
transition: transform 0.14s ease, opacity 0.14s ease,
|
||||
box-shadow 0.14s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-keybinding {
|
||||
// "marching ants" outline hinting that the strip accepts the drop —
|
||||
// SVG because CSS dashed outlines/borders can't animate their dash offset
|
||||
.color-picker__top-picks__dnd-outline {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
width: calc(100% + 10px);
|
||||
height: calc(100% + 10px);
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
rect {
|
||||
x: 0.5px;
|
||||
y: 0.5px;
|
||||
rx: 6px;
|
||||
width: calc(100% - 1px);
|
||||
height: calc(100% - 1px);
|
||||
fill: none;
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
animation: excalidraw-color-dnd-march 0.8s linear infinite;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-type-canvasBackground .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementStroke .color-picker-keybinding {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: #000;
|
||||
@keyframes excalidraw-color-dnd-march {
|
||||
to {
|
||||
// one full dash+gap period per cycle → seamless loop
|
||||
stroke-dashoffset: -8;
|
||||
}
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
// the pick being reordered — its ghost is following the pointer
|
||||
.color-picker__button.is-dnd-source {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
// slot a dragged swatch would replace
|
||||
.color-picker__button.is-dnd-target {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
// this color is already pinned — drop not allowed
|
||||
.color-picker__button.is-dnd-duplicate {
|
||||
animation: excalidraw-color-dnd-duplicate 0.45s ease-in-out infinite
|
||||
alternate;
|
||||
}
|
||||
|
||||
@keyframes excalidraw-color-dnd-duplicate {
|
||||
from {
|
||||
box-shadow: 0 0 0 2px var(--color-danger, #e03131);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 0 3px var(--color-danger, #e03131);
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drag & drop ghost — appended to document.body (outside the .excalidraw
|
||||
// scope) so it can follow the pointer without overflow clipping
|
||||
.excalidraw-color-dnd-ghost {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
// NOTE transform is deliberately not transitioned here (tracks the pointer
|
||||
// every frame); --dropping adds it for the fly-to-target animation
|
||||
transition: width 0.15s ease, height 0.15s ease;
|
||||
|
||||
&__swatch {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&.is-transparent {
|
||||
background-color: #fff;
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
|
||||
background-position: left center;
|
||||
}
|
||||
}
|
||||
|
||||
&--lifted &__swatch {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&--over &__swatch {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&--blocked &__swatch {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
&--dropping {
|
||||
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.18s ease,
|
||||
height 0.18s ease;
|
||||
}
|
||||
|
||||
&--dropping &__swatch {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&--fade &__swatch {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body.excalidraw-color-dnd-active {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
|
||||
&,
|
||||
* {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Popover } from "radix-ui";
|
||||
import clsx from "clsx";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { memo, useRef, useEffect } from "react";
|
||||
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
isColorDark,
|
||||
isWritableElement,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { t } from "../../i18n";
|
||||
import { useExcalidrawContainer, useStylesPanelMode } from "../App";
|
||||
import { useApp, useExcalidrawContainer, useStylesPanelMode } from "../App";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
@@ -31,6 +35,11 @@ import { Picker } from "./Picker";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPickerDnDContext,
|
||||
useColorPickerDnD,
|
||||
useTopPicksDnD,
|
||||
} from "./topPicksDnD";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
|
||||
@@ -52,6 +61,12 @@ interface ColorPickerProps {
|
||||
palette?: ColorPaletteCustom | null;
|
||||
topPicks?: ColorTuple;
|
||||
updateData: (formData?: any) => void;
|
||||
/** palette colors to hide from the popup, keeping hotkey positions */
|
||||
excludedColors?: readonly string[];
|
||||
/** allow users to customize the top picks strip by drag & dropping colors
|
||||
* from the picker popup onto it. The value names the
|
||||
* `appState.colorTopPicks` slot the customization is stored in */
|
||||
customizableTopPicks?: keyof AppState["colorTopPicks"];
|
||||
}
|
||||
|
||||
const ColorPickerPopupContent = ({
|
||||
@@ -64,6 +79,7 @@ const ColorPickerPopupContent = ({
|
||||
updateData,
|
||||
getOpenPopup,
|
||||
appState,
|
||||
excludedColors,
|
||||
}: Pick<
|
||||
ColorPickerProps,
|
||||
| "type"
|
||||
@@ -74,10 +90,12 @@ const ColorPickerPopupContent = ({
|
||||
| "palette"
|
||||
| "updateData"
|
||||
| "appState"
|
||||
| "excludedColors"
|
||||
> & {
|
||||
getOpenPopup: () => AppState["openPopup"];
|
||||
}) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const app = useApp();
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactMode = stylesPanelMode !== "full";
|
||||
const isMobileMode = stylesPanelMode === "mobile";
|
||||
@@ -113,9 +131,33 @@ const ColorPickerPopupContent = ({
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onFocusOutside={(event) => {
|
||||
const target = event.target;
|
||||
// focus moving into the top-picks context menu — let the menu keep
|
||||
// it (stealing it back would clear the menu's focus-driven
|
||||
// `data-highlighted` styling and break its keyboard navigation)
|
||||
if (
|
||||
target instanceof app.ownerWindow.HTMLElement &&
|
||||
target.closest(".color-picker__context-menu")
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// refocus due to eye dropper
|
||||
if (!isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
if (
|
||||
target instanceof app.ownerWindow.HTMLElement &&
|
||||
target.matches(":active")
|
||||
) {
|
||||
// the focus escaped because a button outside is being pressed
|
||||
// (e.g. a top pick) — stealing focus mid-press makes the browser
|
||||
// drop the button's activation (`:active` styling), so wait for
|
||||
// the release
|
||||
app.ownerWindow.addEventListener("pointerup", focusPickerContent, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
focusPickerContent();
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
}}
|
||||
@@ -136,8 +178,8 @@ const ColorPickerPopupContent = ({
|
||||
|
||||
// Refocus text editor when popover closes if we were editing text
|
||||
if (appState.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
app.ownerWindow.setTimeout(() => {
|
||||
const textEditor = app.ownerDocument.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
@@ -151,18 +193,20 @@ const ColorPickerPopupContent = ({
|
||||
<Picker
|
||||
ref={colorPickerContentRef}
|
||||
palette={palette}
|
||||
excludedColors={excludedColors}
|
||||
color={color}
|
||||
theme={appState.theme}
|
||||
onChange={(changedColor) => {
|
||||
// Save caret position before color change if editing text
|
||||
const savedSelection = appState.editingTextElement
|
||||
? saveCaretPosition()
|
||||
? saveCaretPosition(app.ownerDocument)
|
||||
: null;
|
||||
|
||||
onChange(changedColor);
|
||||
|
||||
// Restore caret position after color change if editing text
|
||||
if (appState.editingTextElement && savedSelection) {
|
||||
restoreCaretPosition(savedSelection);
|
||||
restoreCaretPosition(savedSelection, app.ownerDocument);
|
||||
}
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
@@ -209,10 +253,18 @@ const ColorPickerPopupContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const isColorPickerPopup = (
|
||||
popup: AppState["openPopup"],
|
||||
): popup is ColorPickerType =>
|
||||
popup === "elementStroke" ||
|
||||
popup === "elementBackground" ||
|
||||
popup === "canvasBackground";
|
||||
|
||||
const ColorPickerTrigger = ({
|
||||
label,
|
||||
color,
|
||||
type,
|
||||
theme,
|
||||
mode = "background",
|
||||
onToggle,
|
||||
editingTextElement,
|
||||
@@ -220,13 +272,19 @@ const ColorPickerTrigger = ({
|
||||
color: string | null;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
theme: Theme;
|
||||
mode?: "background" | "stroke";
|
||||
onToggle: () => void;
|
||||
editingTextElement?: boolean;
|
||||
}) => {
|
||||
const app = useApp();
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactMode = stylesPanelMode !== "full";
|
||||
const isMobileMode = stylesPanelMode === "mobile";
|
||||
const dnd = useColorPickerDnD();
|
||||
const displayColor = color
|
||||
? applyDarkModeFilter(color, theme === THEME.DARK)
|
||||
: null;
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// use pointerdown so we run before outside-close logic
|
||||
e.preventDefault();
|
||||
@@ -234,7 +292,7 @@ const ColorPickerTrigger = ({
|
||||
|
||||
// If editing text, temporarily disable the wysiwyg blur event
|
||||
if (editingTextElement) {
|
||||
temporarilyDisableTextEditorBlur();
|
||||
temporarilyDisableTextEditorBlur(app.ownerDocument);
|
||||
}
|
||||
|
||||
onToggle();
|
||||
@@ -251,7 +309,7 @@ const ColorPickerTrigger = ({
|
||||
"mobile-border": isMobileMode,
|
||||
})}
|
||||
aria-label={label}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
style={displayColor ? { "--swatch-color": displayColor } : undefined}
|
||||
title={
|
||||
type === "elementStroke"
|
||||
? t("labels.showStroke")
|
||||
@@ -259,6 +317,11 @@ const ColorPickerTrigger = ({
|
||||
}
|
||||
data-openpopup={type}
|
||||
onClick={handleClick}
|
||||
// the active-color swatch can be dragged onto the top-picks strip to
|
||||
// pin the current (possibly custom) color
|
||||
onPointerDown={
|
||||
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||
{isCompactMode && color && mode === "stroke" && (
|
||||
@@ -266,7 +329,8 @@ const ColorPickerTrigger = ({
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
displayColor &&
|
||||
isColorDark(displayColor, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
? "#fff"
|
||||
: "#111",
|
||||
}}
|
||||
@@ -279,7 +343,7 @@ const ColorPickerTrigger = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorPicker = ({
|
||||
const ColorPickerComponent = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
@@ -289,6 +353,8 @@ export const ColorPicker = ({
|
||||
topPicks,
|
||||
updateData,
|
||||
appState,
|
||||
excludedColors,
|
||||
customizableTopPicks,
|
||||
}: ColorPickerProps) => {
|
||||
const openRef = useRef(appState.openPopup);
|
||||
useEffect(() => {
|
||||
@@ -297,8 +363,43 @@ export const ColorPicker = ({
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactMode = stylesPanelMode !== "full";
|
||||
|
||||
const isTopPicksCustomizable = !!customizableTopPicks && !isCompactMode;
|
||||
|
||||
// user-pinned picks trump the (host-provided or default) baseline
|
||||
const customTopPicks =
|
||||
isTopPicksCustomizable && customizableTopPicks
|
||||
? appState.colorTopPicks?.[customizableTopPicks]
|
||||
: null;
|
||||
|
||||
// fully-resolved picks currently displayed in the strip — the baseline the
|
||||
// drag & drop customization starts from
|
||||
const effectiveTopPicks = customTopPicks?.length
|
||||
? customTopPicks
|
||||
: topPicks ??
|
||||
(type === "elementStroke"
|
||||
? DEFAULT_ELEMENT_STROKE_PICKS
|
||||
: DEFAULT_ELEMENT_BACKGROUND_PICKS);
|
||||
|
||||
const dnd = useTopPicksDnD({
|
||||
enabled: isTopPicksCustomizable,
|
||||
picks: effectiveTopPicks,
|
||||
onPicksChange: (picks) => {
|
||||
if (!customizableTopPicks) {
|
||||
return;
|
||||
}
|
||||
// NOTE the comparator comparing `appState.colorTopPicks` is what keeps
|
||||
// this captured spread fresh — see `areColorPickerPropsEqual`
|
||||
updateData({
|
||||
colorTopPicks: {
|
||||
...appState.colorTopPicks,
|
||||
[customizableTopPicks]: picks,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ColorPickerDnDContext.Provider value={isTopPicksCustomizable ? dnd : null}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -308,10 +409,37 @@ export const ColorPicker = ({
|
||||
>
|
||||
{!isCompactMode && (
|
||||
<TopPicks
|
||||
theme={appState.theme}
|
||||
activeColor={color}
|
||||
onChange={onChange}
|
||||
onChange={(pickedColor) => {
|
||||
onChange(pickedColor);
|
||||
// if another color-picker popup is open, follow the user's
|
||||
// focus over to this picker (same as clicking its trigger)
|
||||
if (
|
||||
isColorPickerPopup(appState.openPopup) &&
|
||||
appState.openPopup !== type
|
||||
) {
|
||||
// deferred: each updateData spreads the full pre-commit
|
||||
// appState, so issuing this in the same task would clobber
|
||||
// the color change committed by `onChange` above
|
||||
setTimeout(() => updateData({ openPopup: type }), 0);
|
||||
}
|
||||
}}
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
topPicks={customTopPicks?.length ? customTopPicks : topPicks}
|
||||
isCustomized={!!customTopPicks?.length}
|
||||
onReset={
|
||||
isTopPicksCustomizable && customizableTopPicks
|
||||
? () => {
|
||||
updateData({
|
||||
colorTopPicks: {
|
||||
...appState.colorTopPicks,
|
||||
[customizableTopPicks]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isCompactMode && <ButtonSeparator />}
|
||||
@@ -328,6 +456,7 @@ export const ColorPicker = ({
|
||||
color={color}
|
||||
label={label}
|
||||
type={type}
|
||||
theme={appState.theme}
|
||||
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||
editingTextElement={!!appState.editingTextElement}
|
||||
onToggle={() => {
|
||||
@@ -352,6 +481,7 @@ export const ColorPicker = ({
|
||||
label={label}
|
||||
elements={elements}
|
||||
palette={palette}
|
||||
excludedColors={excludedColors}
|
||||
updateData={updateData}
|
||||
getOpenPopup={() => openRef.current}
|
||||
appState={appState}
|
||||
@@ -359,6 +489,51 @@ export const ColorPicker = ({
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
</ColorPickerDnDContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ColorPicker renders on every styles-panel render (selection churn during
|
||||
* box-select and the like), while its actual inputs are a handful of resolved
|
||||
* primitives and reference-stable objects — cheap `===` checks, no deep
|
||||
* comparisons:
|
||||
* - `palette` / `topPicks` / `excludedColors` are module constants,
|
||||
* - `appState.colorTopPicks` identity changes exactly when its content does.
|
||||
*
|
||||
* Function props (`onChange`, `updateData`) are deliberately ignored — they
|
||||
* are recreated every render but semantically stable (they read fresh state
|
||||
* at call time). Same for `elements`, which is only sampled once when the
|
||||
* popup opens (most-used custom colors).
|
||||
*/
|
||||
const areColorPickerPropsEqual = (
|
||||
prev: ColorPickerProps,
|
||||
next: ColorPickerProps,
|
||||
) => {
|
||||
// the open popup is interactive (hotkeys, eye dropper, most-used custom
|
||||
// colors) — never memoize it, including the open/close transitions
|
||||
if (
|
||||
prev.appState.openPopup === prev.type ||
|
||||
next.appState.openPopup === next.type
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
prev.type === next.type &&
|
||||
prev.color === next.color &&
|
||||
prev.label === next.label &&
|
||||
// popup open/close is rare, and the strip's click handler branches on
|
||||
// which popup is open (popup-switching) — keep its closure fresh
|
||||
prev.appState.openPopup === next.appState.openPopup &&
|
||||
prev.palette === next.palette &&
|
||||
prev.topPicks === next.topPicks &&
|
||||
prev.excludedColors === next.excludedColors &&
|
||||
prev.customizableTopPicks === next.customizableTopPicks &&
|
||||
prev.appState.theme === next.appState.theme &&
|
||||
prev.appState.colorTopPicks === next.appState.colorTopPicks &&
|
||||
// the trigger tweaks its click behavior while a text element is edited
|
||||
!!prev.appState.editingTextElement === !!next.appState.editingTextElement
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorPicker = memo(ColorPickerComponent, areColorPickerPropsEqual);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user