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 |
+1
-1
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
|
||||
FAST_REFRESH=false
|
||||
|
||||
# The port the run the dev server
|
||||
VITE_APP_PORT=3005
|
||||
VITE_APP_PORT=3001
|
||||
|
||||
#Debug flags
|
||||
|
||||
|
||||
@@ -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
-2
@@ -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,7 +953,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
// aiEnabled={false}
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
.tta-chat-empty-storage-notice {
|
||||
font-size: 0.75rem;
|
||||
padding: 0 2rem;
|
||||
color: var(--text-secondary-color);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tta-chat-empty-storage-notice__more {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit !important;
|
||||
cursor: pointer;
|
||||
font: inherit !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dashed;
|
||||
text-decoration-color: #bbb;
|
||||
text-decoration-thickness: from-font;
|
||||
text-underline-offset: 0.2em;
|
||||
|
||||
@at-root .excalidraw.theme--dark #{&} {
|
||||
text-decoration-color: #555;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 1px 0 0 currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-chat-empty-storage-dialog {
|
||||
color: var(--text-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,204 +1,30 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
DiagramToCodePlugin,
|
||||
exportToBlob,
|
||||
getNonDeletedElements,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
TTADialog,
|
||||
TTADefaultTransportAdapter,
|
||||
TTDDefaultTransportAdapter,
|
||||
parseSSEStream,
|
||||
TTDDialog,
|
||||
TTDRateLimitWarningContent,
|
||||
useI18n,
|
||||
TTDStreamFetch,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import Trans from "@excalidraw/excalidraw/components/Trans";
|
||||
import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { formatTimeToHourMinute, safelyParseJSON } from "@excalidraw/common";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
|
||||
import type { StreamChunk } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
|
||||
|
||||
import "./AI.scss";
|
||||
|
||||
const TTA_LIMIT_UPSELL_URL = `${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=aiChatBanner#excalidraw-redirect`;
|
||||
|
||||
const TTD_LIMIT_UPSELL_URL = `${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=ttdChatBanner#excalidraw-redirect`;
|
||||
|
||||
const AI_BACKEND_BASE_URL = import.meta.env.VITE_APP_AI_BACKEND?.replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
const postJson =
|
||||
(url: string) =>
|
||||
async ({
|
||||
method,
|
||||
headers,
|
||||
payload,
|
||||
signal,
|
||||
}: {
|
||||
method: "POST";
|
||||
headers: Record<string, string>;
|
||||
payload: unknown;
|
||||
signal?: AbortSignal;
|
||||
}) =>
|
||||
fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
const postJsonWithoutSignal =
|
||||
(url: string) =>
|
||||
async ({
|
||||
method,
|
||||
headers,
|
||||
payload,
|
||||
}: {
|
||||
method: "POST";
|
||||
headers: Record<string, string>;
|
||||
payload: unknown;
|
||||
}) =>
|
||||
fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const ttdTransport = new TTDDefaultTransportAdapter({
|
||||
stream: postJson(
|
||||
`${AI_BACKEND_BASE_URL}/v1/ai/text-to-diagram/chat-streaming`,
|
||||
),
|
||||
});
|
||||
const ttaTransport = new TTADefaultTransportAdapter({
|
||||
stream: postJson(`${AI_BACKEND_BASE_URL}/v1/ai/tta/generate/stream`),
|
||||
truncate: postJsonWithoutSignal(
|
||||
`${AI_BACKEND_BASE_URL}/v1/ai/tta/chat/truncate`,
|
||||
),
|
||||
});
|
||||
|
||||
const TTASystemRateLimitWarning = ({
|
||||
title,
|
||||
timestamp,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
timestamp: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
width: "100%",
|
||||
paddingBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "80%",
|
||||
minWidth: "6rem",
|
||||
padding: "0.75rem 1rem",
|
||||
borderRadius: "var(--border-radius-md)",
|
||||
background: "var(--color-warning)",
|
||||
color: "var(--color-warning-color)",
|
||||
boxShadow: "var(--chat-msg-shadow)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{title}</span>
|
||||
<span style={{ fontSize: "0.625rem" }}>{timestamp}</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: "monospace" }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TTAWelcomeScreen = () => {
|
||||
const { t } = useI18n();
|
||||
const [isStorageInfoDialogOpen, setIsStorageInfoDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tta-chat-empty-tagline">
|
||||
{t("ai.chat.emptyState.guidance")}
|
||||
</div>
|
||||
<div className="tta-chat-empty-storage-notice">
|
||||
{t("ai.chat.emptyState.storageNotice")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="tta-chat-empty-storage-notice__more"
|
||||
onClick={() => setIsStorageInfoDialogOpen(true)}
|
||||
>
|
||||
{t("ai.chat.emptyState.seeMore")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isStorageInfoDialogOpen && (
|
||||
<Dialog
|
||||
title={t("ai.chat.emptyState.storageDialog.title")}
|
||||
size="small"
|
||||
onCloseRequest={() => setIsStorageInfoDialogOpen(false)}
|
||||
>
|
||||
<div className="tta-chat-empty-storage-dialog">
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="ai.chat.emptyState.storageDialog.description"
|
||||
link={(el) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=aiUsageNotice#excalidraw-redirect`}
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
br={() => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIComponents = ({
|
||||
excalidrawAPI,
|
||||
}: {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
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
|
||||
@@ -227,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({
|
||||
@@ -270,74 +96,79 @@ 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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<TTDDialog
|
||||
transportAdapter={ttdTransport}
|
||||
renderWarning={(warning) => {
|
||||
return (
|
||||
<TTDRateLimitWarningContent
|
||||
warning={warning}
|
||||
onUpgrade={() => {
|
||||
window.open(TTD_LIMIT_UPSELL_URL, "_blank", "noopener");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
onTextSubmit={async (props) => {
|
||||
const { onChunk, onStreamCreated, signal, messages } = props;
|
||||
|
||||
const result = await TTDStreamFetch({
|
||||
url: `${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/chat-streaming`,
|
||||
messages,
|
||||
onChunk,
|
||||
onStreamCreated,
|
||||
extractRateLimits: true,
|
||||
signal,
|
||||
});
|
||||
|
||||
return result;
|
||||
}}
|
||||
persistenceAdapter={TTDIndexedDBAdapter}
|
||||
/>
|
||||
|
||||
<TTADialog
|
||||
transportAdapter={ttaTransport}
|
||||
maxImages={1}
|
||||
renderWelcomeScreen={() => <TTAWelcomeScreen />}
|
||||
onMaxImages={() => (
|
||||
<Trans
|
||||
i18nKey="ai.chat.imageLimit"
|
||||
link={(el) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=ttt-imageLimit#excalidraw-redirect`}
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderWarning={(warning, message) => {
|
||||
return (
|
||||
<TTASystemRateLimitWarning
|
||||
title={t("chat.role.system")}
|
||||
timestamp={formatTimeToHourMinute(
|
||||
message.createdAt ?? Date.now(),
|
||||
)}
|
||||
>
|
||||
<TTDRateLimitWarningContent
|
||||
warning={warning}
|
||||
onUpgrade={() => {
|
||||
window.open(TTA_LIMIT_UPSELL_URL, "_blank", "noopener");
|
||||
}}
|
||||
/>
|
||||
</TTASystemRateLimitWarning>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -46,8 +47,6 @@ import { FileStatusStore } from "./fileStatusStore";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
import { getPersistableElements } from "./index";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
export const localStorageQuotaExceededAtom = atom(false);
|
||||
@@ -91,9 +90,11 @@ const saveDataStateToLocalStorage = (
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
// like the collab/Firebase path, never persist TTA intermediate
|
||||
// streaming-preview elements (tta_rewrite_final.md §2.4/N3)
|
||||
JSON.stringify(getPersistableElements(getNonDeletedElements(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,
|
||||
|
||||
@@ -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,
|
||||
@@ -32,7 +33,7 @@ import type {
|
||||
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
|
||||
import { getPersistableElements, getSyncableElements } from ".";
|
||||
import { getSyncableElements } from ".";
|
||||
|
||||
import type { SyncableExcalidrawElement } from ".";
|
||||
import type Portal from "../collab/Portal";
|
||||
@@ -86,6 +87,7 @@ export const loadFirebaseStorage = async () => {
|
||||
|
||||
type FirebaseStoredScene = {
|
||||
sceneVersion: number;
|
||||
schemaVersion?: number;
|
||||
iv: Bytes;
|
||||
ciphertext: Bytes;
|
||||
};
|
||||
@@ -133,7 +135,7 @@ export const isSavedToFirebase = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): boolean => {
|
||||
if (portal.socket && portal.roomId && portal.roomKey) {
|
||||
const sceneVersion = getSceneVersion(getPersistableElements(elements));
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
|
||||
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
|
||||
}
|
||||
@@ -175,14 +177,11 @@ const createFirebaseSceneDocument = async (
|
||||
elements: readonly SyncableExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
const persistableElements = getPersistableElements(elements);
|
||||
const sceneVersion = getSceneVersion(persistableElements);
|
||||
const { ciphertext, iv } = await encryptElements(
|
||||
roomKey,
|
||||
persistableElements,
|
||||
);
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
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;
|
||||
@@ -204,7 +203,6 @@ export const saveToFirebase = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistableElements = getPersistableElements(elements);
|
||||
const firestore = _getFirestore();
|
||||
const docRef = doc(firestore, "scenes", roomId);
|
||||
|
||||
@@ -212,10 +210,7 @@ export const saveToFirebase = async (
|
||||
const snapshot = await transaction.get(docRef);
|
||||
|
||||
if (!snapshot.exists()) {
|
||||
const storedScene = await createFirebaseSceneDocument(
|
||||
persistableElements,
|
||||
roomKey,
|
||||
);
|
||||
const storedScene = await createFirebaseSceneDocument(elements, roomKey);
|
||||
|
||||
transaction.set(docRef, storedScene);
|
||||
|
||||
@@ -224,11 +219,13 @@ 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(
|
||||
persistableElements,
|
||||
elements,
|
||||
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
||||
appState,
|
||||
),
|
||||
@@ -246,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);
|
||||
@@ -269,6 +268,7 @@ export const loadFromFirebase = async (
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
schemaVersion: getSchemaVersion(storedScene),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
IV_LENGTH_BYTES,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { isIntermediatePreviewElement } from "@excalidraw/excalidraw/TTA/insertAISkeletons";
|
||||
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";
|
||||
|
||||
@@ -44,19 +44,6 @@ import type { WS_SUBTYPES } from "../app_constants";
|
||||
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"SyncableExcalidrawElement">;
|
||||
|
||||
export { isIntermediatePreviewElement };
|
||||
|
||||
/**
|
||||
* Filters out TTA intermediate streaming-preview elements — real scene
|
||||
* elements that must never be persisted (tta_rewrite_final.md §2.4/N3).
|
||||
*/
|
||||
export const getPersistableElements = <TElement extends ExcalidrawElement>(
|
||||
elements: readonly TElement[],
|
||||
) =>
|
||||
elements.filter(
|
||||
(element) => !isIntermediatePreviewElement(element),
|
||||
) as TElement[];
|
||||
|
||||
export const isSyncableElement = (
|
||||
element: OrderedExcalidrawElement,
|
||||
): element is SyncableExcalidrawElement => {
|
||||
@@ -98,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: {
|
||||
@@ -210,6 +199,7 @@ const legacy_decodeFromBackend = async ({
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
schemaVersion: getSchemaVersion(data),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -240,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]);
|
||||
};
|
||||
@@ -117,6 +117,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Following placeholder is replaced during the build step -->
|
||||
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
@@ -130,10 +133,6 @@
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<!-- Following placeholder is replaced during PROD build step -->
|
||||
<!-- see scripts/woff2/woff2-vite-plugins.js -->
|
||||
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
|
||||
@@ -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,26 +48,23 @@ export const getDateTime = () => {
|
||||
export const capitalizeString = (str: string) =>
|
||||
str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
export const formatTimeToHourMinute = (
|
||||
value: Date | number,
|
||||
opts?: { locale?: string | string[]; hour12?: boolean },
|
||||
) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString(opts?.locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(opts?.hour12 === undefined ? {} : { hour12: opts.hour12 }),
|
||||
});
|
||||
};
|
||||
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,
|
||||
@@ -76,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"))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,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,
|
||||
@@ -491,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 &&
|
||||
@@ -508,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 = (
|
||||
|
||||
+126
-65
@@ -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 = (
|
||||
@@ -2220,33 +2271,17 @@ export const fixDuplicatedBindingsAfterDuplication = (
|
||||
if (isElbowArrow(duplicateElement)) {
|
||||
Object.assign(
|
||||
duplicateElement,
|
||||
normalizeElbowArrow(duplicateElement, duplicateElementsMap),
|
||||
updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
|
||||
points: [
|
||||
duplicateElement.points[0],
|
||||
duplicateElement.points[duplicateElement.points.length - 1],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeElbowArrow = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) =>
|
||||
updateElbowArrowPoints(arrow, elementsMap, {
|
||||
points: [arrow.points[0], arrow.points[arrow.points.length - 1]],
|
||||
});
|
||||
|
||||
export const normalizeElbowArrows = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
elementsMap?: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const map =
|
||||
elementsMap ?? (arrayToMap(elements) as NonDeletedSceneElementsMap);
|
||||
return elements.map((element) =>
|
||||
isElbowArrow(element)
|
||||
? { ...element, ...normalizeElbowArrow(element, map) }
|
||||
: element,
|
||||
);
|
||||
};
|
||||
|
||||
export const fixBindingsAfterDeletion = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
deletedElements: readonly ExcalidrawElement[],
|
||||
@@ -3135,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;
|
||||
|
||||
@@ -103,7 +103,7 @@ export const getApproxMinLineHeight = (
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
const PROVIDER_KEY = Symbol.for("excalidraw.textMetricsProvider");
|
||||
let textMetricsProvider: TextMetricsProvider | undefined;
|
||||
|
||||
/**
|
||||
* Set a custom text metrics provider.
|
||||
@@ -111,7 +111,7 @@ const PROVIDER_KEY = Symbol.for("excalidraw.textMetricsProvider");
|
||||
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
|
||||
*/
|
||||
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
|
||||
(globalThis as any)[PROVIDER_KEY] = provider;
|
||||
textMetricsProvider = provider;
|
||||
};
|
||||
|
||||
export interface TextMetricsProvider {
|
||||
@@ -150,15 +150,11 @@ class CanvasTextMetricsProvider implements TextMetricsProvider {
|
||||
}
|
||||
|
||||
export const getLineWidth = (text: string, font: FontString) => {
|
||||
let provider: TextMetricsProvider | undefined = (globalThis as any)[
|
||||
PROVIDER_KEY
|
||||
];
|
||||
|
||||
if (!provider) {
|
||||
provider = new CanvasTextMetricsProvider();
|
||||
if (!textMetricsProvider) {
|
||||
textMetricsProvider = new CanvasTextMetricsProvider();
|
||||
}
|
||||
|
||||
return provider.getLineWidth(text, font);
|
||||
return textMetricsProvider.getLineWidth(text, font);
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
|
||||
+110
-109
@@ -11,30 +11,42 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
getLineHeight,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
isTransparent,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkOptional, Mutable } from "@excalidraw/common/utility-types";
|
||||
import type { MarkOptional } from "@excalidraw/common/utility-types";
|
||||
|
||||
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, isBindableElement } 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);
|
||||
@@ -256,8 +308,8 @@ const bindLinearElementToElement = (
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
endBoundElement?: ExcalidrawElement;
|
||||
} => {
|
||||
let startBoundElement: ExcalidrawElement | undefined;
|
||||
let endBoundElement: ExcalidrawElement | undefined;
|
||||
let startBoundElement;
|
||||
let endBoundElement;
|
||||
|
||||
Object.assign(linearElement, {
|
||||
startBinding: linearElement?.startBinding || null,
|
||||
@@ -322,12 +374,6 @@ const bindLinearElementToElement = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "frame": {
|
||||
if (existingElement && isBindableElement(existingElement)) {
|
||||
startBoundElement = existingElement;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
@@ -337,9 +383,7 @@ const bindLinearElementToElement = (
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startBoundElement && isBindableElement(startBoundElement)) {
|
||||
bindBindingElement(
|
||||
linearElement,
|
||||
startBoundElement as NonDeleted<ExcalidrawBindableElement>,
|
||||
@@ -406,12 +450,6 @@ const bindLinearElementToElement = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "frame": {
|
||||
if (existingElement && isBindableElement(existingElement)) {
|
||||
endBoundElement = existingElement;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
@@ -421,9 +459,7 @@ const bindLinearElementToElement = (
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endBoundElement && isBindableElement(endBoundElement)) {
|
||||
bindBindingElement(
|
||||
linearElement,
|
||||
endBoundElement as NonDeleted<ExcalidrawBindableElement>,
|
||||
@@ -541,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) {
|
||||
@@ -644,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;
|
||||
}
|
||||
|
||||
@@ -684,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,
|
||||
@@ -784,9 +848,7 @@ export const convertToExcalidrawElements = (
|
||||
Object.assign(elementInFrame, { frameId: frame.id });
|
||||
|
||||
elementInFrame?.boundElements?.forEach((boundElement) => {
|
||||
const boundElementId =
|
||||
oldToNewElementIdMap.get(boundElement.id) ?? boundElement.id;
|
||||
const ele = elementStore.getElement(boundElementId);
|
||||
const ele = elementStore.getElement(boundElement.id);
|
||||
if (!ele) {
|
||||
throw new Error(
|
||||
`Bound element with id ${boundElement.id} doesn't exist`,
|
||||
@@ -807,10 +869,10 @@ export const convertToExcalidrawElements = (
|
||||
maxX = maxX + PADDING;
|
||||
maxY = maxY + PADDING;
|
||||
|
||||
const frameX = frame?.x ?? minX;
|
||||
const frameY = frame?.y ?? minY;
|
||||
const frameWidth = frame?.width ?? maxX - minX;
|
||||
const frameHeight = frame?.height ?? maxY - minY;
|
||||
const frameX = frame?.x || minX;
|
||||
const frameY = frame?.y || minY;
|
||||
const frameWidth = frame?.width || maxX - minX;
|
||||
const frameHeight = frame?.height || maxY - minY;
|
||||
|
||||
Object.assign(frame, {
|
||||
x: frameX,
|
||||
@@ -818,75 +880,14 @@ export const convertToExcalidrawElements = (
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
// remap old ids of pre-existing relationships (i.e. those not created through
|
||||
// skeleton transformation)
|
||||
if (opts?.regenerateIds && oldToNewElementIdMap.size) {
|
||||
const remapId = (id?: string | null) =>
|
||||
id ? oldToNewElementIdMap.get(id) ?? id : id;
|
||||
|
||||
const remapBoundElements = (
|
||||
boundElements?: ExcalidrawElement["boundElements"],
|
||||
) => {
|
||||
if (!boundElements?.length) {
|
||||
return boundElements;
|
||||
}
|
||||
let didChange = false;
|
||||
const nextBoundElements = boundElements.map((boundElement) => {
|
||||
const nextId = remapId(boundElement.id);
|
||||
if (nextId !== boundElement.id) {
|
||||
didChange = true;
|
||||
return { ...boundElement, id: nextId };
|
||||
}
|
||||
return boundElement;
|
||||
});
|
||||
return didChange ? nextBoundElements : boundElements;
|
||||
};
|
||||
|
||||
for (const element of elementStore.excalidrawElements.values()) {
|
||||
const mutableElement = element as Mutable<ExcalidrawElement>;
|
||||
|
||||
if ("containerId" in mutableElement && mutableElement.containerId) {
|
||||
mutableElement.containerId =
|
||||
remapId(mutableElement.containerId) ?? null;
|
||||
}
|
||||
|
||||
if (mutableElement.boundElements?.length) {
|
||||
mutableElement.boundElements = remapBoundElements(
|
||||
mutableElement.boundElements,
|
||||
) as typeof mutableElement.boundElements;
|
||||
}
|
||||
|
||||
if (
|
||||
"startBinding" in mutableElement &&
|
||||
mutableElement.startBinding?.elementId
|
||||
) {
|
||||
const nextId =
|
||||
remapId(mutableElement.startBinding.elementId) ??
|
||||
mutableElement.startBinding.elementId;
|
||||
mutableElement.startBinding = {
|
||||
...mutableElement.startBinding,
|
||||
elementId: nextId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
"endBinding" in mutableElement &&
|
||||
mutableElement.endBinding?.elementId
|
||||
) {
|
||||
const nextId =
|
||||
remapId(mutableElement.endBinding.elementId) ??
|
||||
mutableElement.endBinding.elementId;
|
||||
mutableElement.endBinding = {
|
||||
...mutableElement.endBinding,
|
||||
elementId: nextId,
|
||||
};
|
||||
}
|
||||
|
||||
if ("frameId" in mutableElement && mutableElement.frameId) {
|
||||
mutableElement.frameId = remapId(mutableElement.frameId) ?? null;
|
||||
}
|
||||
if (
|
||||
isDevEnv() &&
|
||||
element.children.length &&
|
||||
(frame?.x || frame?.y || frame?.width || frame?.height)
|
||||
) {
|
||||
console.info(
|
||||
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { formatTimeToHourMinute } from "@excalidraw/common";
|
||||
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
import { getConversationPreviewMessage } from "./chatHelpers";
|
||||
import { useAIAssistantPreview } from "./useAIAssistantPreview";
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ChatConversation,
|
||||
TTADialogRenderWelcomeScreen,
|
||||
TTARateLimits,
|
||||
} from "./types";
|
||||
|
||||
type TTAChatEmptyStateProps = {
|
||||
latestHistoryChat: ChatConversation | null;
|
||||
onSelectHistoryChat: (chat: ChatConversation) => void;
|
||||
rateLimits: TTARateLimits | null;
|
||||
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
|
||||
};
|
||||
|
||||
export const TTAChatEmptyState = ({
|
||||
latestHistoryChat,
|
||||
onSelectHistoryChat,
|
||||
rateLimits,
|
||||
renderWelcomeScreen,
|
||||
}: TTAChatEmptyStateProps) => {
|
||||
const { t } = useI18n();
|
||||
const previewMessage = latestHistoryChat
|
||||
? getConversationPreviewMessage(latestHistoryChat.messages)
|
||||
: null;
|
||||
const renderedWelcomeScreen = renderWelcomeScreen?.({ rateLimits });
|
||||
|
||||
return (
|
||||
<div className="tta-chat-empty-state">
|
||||
{latestHistoryChat && (
|
||||
<div className="tta-chat-empty-top">
|
||||
<button
|
||||
type="button"
|
||||
className="tta-chat-empty-resume-card"
|
||||
onClick={() => onSelectHistoryChat(latestHistoryChat)}
|
||||
>
|
||||
<div className="tta-chat-empty-resume-card__preview">
|
||||
{previewMessage ? (
|
||||
<TTAChatEmptyStatePreview
|
||||
message={previewMessage}
|
||||
alt={t("ai.chat.preview")}
|
||||
/>
|
||||
) : (
|
||||
<div className="tta-chat-empty-resume-card__placeholder" />
|
||||
)}
|
||||
</div>
|
||||
<div className="tta-chat-empty-resume-card__content">
|
||||
<div className="tta-chat-empty-resume-card__label">
|
||||
{t("ai.chat.emptyState.resume")}
|
||||
</div>
|
||||
<div className="tta-chat-empty-resume-card__title">
|
||||
{latestHistoryChat.title}
|
||||
</div>
|
||||
<div className="tta-chat-empty-resume-card__meta">
|
||||
{t("ai.chat.emptyState.lastUpdated", {
|
||||
time: formatTimeToHourMinute(latestHistoryChat.updatedAt),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="tta-chat-empty-middle">
|
||||
<div className="tta-chat-empty-intro">
|
||||
{renderedWelcomeScreen !== undefined ? (
|
||||
renderedWelcomeScreen
|
||||
) : (
|
||||
<div className="tta-chat-empty-tagline">
|
||||
{t("ai.chat.emptyState.guidance")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TTAChatEmptyStatePreview = ({
|
||||
message,
|
||||
alt,
|
||||
}: {
|
||||
message: AssistantMessage;
|
||||
alt: string;
|
||||
}) => {
|
||||
const { previewSvg } = useAIAssistantPreview(message);
|
||||
|
||||
return previewSvg ? (
|
||||
<img src={previewSvg} alt={alt} />
|
||||
) : (
|
||||
<div className="tta-chat-empty-resume-card__placeholder" />
|
||||
);
|
||||
};
|
||||
@@ -1,537 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
RetryIcon,
|
||||
copyIcon,
|
||||
tablerCheckIcon as checkIcon,
|
||||
TrashIcon,
|
||||
stackPushIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { ChatMessage, ChatMessageActionButton } from "../components/AI";
|
||||
|
||||
import { getAIErrorMessageKey } from "./utils";
|
||||
import { useAIAssistantPreview } from "./useAIAssistantPreview";
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantStatus,
|
||||
ChatMessage as ChatMessageType,
|
||||
SystemWarningMessage,
|
||||
TTAChatScrollOptions,
|
||||
TTADialogRenderWarning,
|
||||
TTARateLimits,
|
||||
UserMessage,
|
||||
} from "./types";
|
||||
import type { AIRateLimitWarningDescriptor } from "../aiWarnings";
|
||||
|
||||
export interface TTAChatMessageProps {
|
||||
message: ChatMessageType;
|
||||
onInsert: () => void;
|
||||
onRetry: () => void;
|
||||
showRetry?: boolean;
|
||||
onDelete: () => void;
|
||||
showDelete?: boolean;
|
||||
onPreview: (url: string) => void;
|
||||
/**
|
||||
* Re-run affordance for a trailing prompt-only user turn
|
||||
* (tta_rewrite_final.md §5.8): a reload or chat switch mid-generation
|
||||
* leaves the chat ending on a user message with no assistant reply —
|
||||
* re-run sends the same prompt + images as a fresh turn.
|
||||
*/
|
||||
onRerun?: () => void;
|
||||
showRerun?: boolean;
|
||||
scrollChatToBottom?: (options?: TTAChatScrollOptions) => void;
|
||||
renderWarning?: TTADialogRenderWarning;
|
||||
rateLimits?: TTARateLimits | null;
|
||||
}
|
||||
|
||||
type TTAUserChatMessageProps = Pick<
|
||||
TTAChatMessageProps,
|
||||
"onPreview" | "onRerun" | "showRerun" | "rateLimits"
|
||||
> & {
|
||||
message: UserMessage;
|
||||
};
|
||||
|
||||
const TTAUserChatMessage = ({
|
||||
message,
|
||||
onPreview,
|
||||
onRerun,
|
||||
showRerun = false,
|
||||
rateLimits,
|
||||
}: TTAUserChatMessageProps) => {
|
||||
const { t } = useI18n();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const handleCopy = useCallback((content: UserMessage["content"]) => {
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
if (copyTimeoutRef.current) {
|
||||
window.clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
copyTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
window.clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showCopy = Boolean(message.content);
|
||||
const actionsNode =
|
||||
showCopy || showRerun ? (
|
||||
<>
|
||||
{showCopy && (
|
||||
<ChatMessageActionButton
|
||||
icon={isCopied ? checkIcon : copyIcon}
|
||||
onClick={() => handleCopy(message.content)}
|
||||
ariaLabel={t("labels.copy")}
|
||||
title={t("labels.copy")}
|
||||
/>
|
||||
)}
|
||||
{showRerun && (
|
||||
<ChatMessageActionButton
|
||||
icon={RetryIcon}
|
||||
label={t("ai.chat.actions.rerun")}
|
||||
onClick={() => onRerun?.()}
|
||||
ariaLabel={t("ai.chat.actions.rerun")}
|
||||
title={t("ai.chat.actions.rerun")}
|
||||
disabled={rateLimits?.rateLimitRemaining === 0}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
role="user"
|
||||
roleLabel={t("ai.chat.roles.you")}
|
||||
timestamp={message.createdAt}
|
||||
content={message.content}
|
||||
images={message.images}
|
||||
onImageClick={onPreview}
|
||||
actions={actionsNode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TTASystemWarningMessageProps = Pick<
|
||||
TTAChatMessageProps,
|
||||
"renderWarning" | "rateLimits"
|
||||
> & {
|
||||
message: SystemWarningMessage;
|
||||
};
|
||||
|
||||
const getRateLimitWarningDescriptor = (
|
||||
variant: AIRateLimitWarningDescriptor["variant"],
|
||||
rateLimits: TTARateLimits | null | undefined,
|
||||
): AIRateLimitWarningDescriptor => ({
|
||||
kind: "rateLimit",
|
||||
variant,
|
||||
rateLimit: rateLimits?.rateLimit,
|
||||
rateLimitRemaining: rateLimits?.rateLimitRemaining,
|
||||
});
|
||||
|
||||
const TTASystemWarningMessage = ({
|
||||
message,
|
||||
renderWarning,
|
||||
rateLimits,
|
||||
}: TTASystemWarningMessageProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const customWarning = renderWarning?.(
|
||||
getRateLimitWarningDescriptor(message.variant, rateLimits),
|
||||
message,
|
||||
);
|
||||
if (customWarning !== undefined) {
|
||||
return <>{customWarning}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
role="system"
|
||||
roleLabel={t("chat.role.system")}
|
||||
timestamp={message.createdAt}
|
||||
content={
|
||||
message.variant === "messageLimitExceeded"
|
||||
? t("chat.rateLimit.messageLimit")
|
||||
: t("chat.rateLimit.generalRateLimit")
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TTAAssistantChatMessageProps = Omit<
|
||||
TTAChatMessageProps,
|
||||
"message" | "onPreview"
|
||||
> & {
|
||||
message: AssistantMessage;
|
||||
};
|
||||
|
||||
const formatElapsedTime = (elapsedMs?: number | null) => {
|
||||
if (elapsedMs == null || !Number.isFinite(elapsedMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
const seconds = totalSeconds % 60;
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(
|
||||
seconds,
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const useLiveNow = (enabled: boolean) => {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [enabled]);
|
||||
|
||||
return now;
|
||||
};
|
||||
|
||||
const getStreamingProgressLabel = (
|
||||
status: Extract<AssistantStatus, { kind: "streaming" }>,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): string | null => {
|
||||
const statusText = status.statusText?.trim();
|
||||
if (statusText) {
|
||||
return statusText;
|
||||
}
|
||||
|
||||
switch (status.phase) {
|
||||
case "thinking":
|
||||
return t("ai.chat.status.thinking");
|
||||
case "finalizing":
|
||||
return t("ai.chat.status.finalizing");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const TTAAssistantChatMessage = ({
|
||||
message,
|
||||
onInsert,
|
||||
onRetry,
|
||||
showRetry = false,
|
||||
onDelete,
|
||||
showDelete = false,
|
||||
scrollChatToBottom,
|
||||
renderWarning,
|
||||
rateLimits,
|
||||
}: TTAAssistantChatMessageProps) => {
|
||||
const { t } = useI18n();
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrolledStreamingPreviewKeyRef = useRef<string | null>(null);
|
||||
const pendingStreamingPreviewScrollKeyRef = useRef<string | null>(null);
|
||||
const { previewSvg, status: previewStatus } = useAIAssistantPreview(message);
|
||||
|
||||
const status = message.status;
|
||||
const isStreaming = status.kind === "streaming";
|
||||
const hasCurrentPreview = Boolean(previewSvg);
|
||||
const assistantError = status.kind === "error" ? status.error : null;
|
||||
const liveNow = useLiveNow(isStreaming);
|
||||
|
||||
const handleGeneratedPreviewClick = useCallback(() => {
|
||||
if (!message.skeletons?.length) {
|
||||
return;
|
||||
}
|
||||
onInsert();
|
||||
}, [message.skeletons?.length, onInsert]);
|
||||
|
||||
const previewKey = message.id;
|
||||
const shouldScrollStreamingPreviewOnLoad =
|
||||
isStreaming && previewStatus === "done" && Boolean(previewSvg);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldScrollStreamingPreviewOnLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrolledStreamingPreviewKeyRef.current === previewKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingStreamingPreviewScrollKeyRef.current = previewKey;
|
||||
}, [previewKey, shouldScrollStreamingPreviewOnLoad]);
|
||||
|
||||
const handleGeneratedPreviewLoad = useCallback(() => {
|
||||
if (scrolledStreamingPreviewKeyRef.current === previewKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pendingStreamingPreviewScrollKeyRef.current !== previewKey &&
|
||||
!shouldScrollStreamingPreviewOnLoad
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingStreamingPreviewScrollKeyRef.current = null;
|
||||
scrolledStreamingPreviewKeyRef.current = previewKey;
|
||||
requestAnimationFrame(() => {
|
||||
scrollChatToBottom?.({
|
||||
keepElementTopVisible: messageContainerRef.current,
|
||||
behavior: "auto",
|
||||
});
|
||||
});
|
||||
}, [previewKey, scrollChatToBottom, shouldScrollStreamingPreviewOnLoad]);
|
||||
|
||||
// --- Content visibility ---
|
||||
// Terminal without a preview: the outcome label ("Generated response." /
|
||||
// "(empty response)"). Streaming and errors render no content text.
|
||||
const visibleContent =
|
||||
status.kind === "done" && !hasCurrentPreview
|
||||
? status.outcome === "generated"
|
||||
? t("ai.chat.status.generatedResponse")
|
||||
: t("ai.chat.status.emptyResponse")
|
||||
: undefined;
|
||||
|
||||
// A failed generation may still carry the partial skeletons streamed before
|
||||
// the failure (e.g. connection interruptions — C2 in tta.md). Surface them
|
||||
// so the user can preview/insert the partial result alongside the error.
|
||||
const hasSalvageablePartialResult = Boolean(
|
||||
assistantError && message.skeletons?.length,
|
||||
);
|
||||
const assistantOutputExists =
|
||||
(!assistantError || hasSalvageablePartialResult) &&
|
||||
!visibleContent &&
|
||||
hasCurrentPreview;
|
||||
|
||||
// --- Error presentation ---
|
||||
const isRateLimitError = assistantError?.code === 429;
|
||||
const isQuotaExhausted = rateLimits?.rateLimitRemaining === 0;
|
||||
const errorPresentation = assistantError
|
||||
? isRateLimitError
|
||||
? isQuotaExhausted
|
||||
? t("chat.rateLimit.messageLimit")
|
||||
: t("chat.rateLimit.generalRateLimit")
|
||||
: t(
|
||||
getAIErrorMessageKey(assistantError, {
|
||||
isOffline:
|
||||
typeof navigator !== "undefined" ? !navigator.onLine : false,
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
// A generation rejected with 429 keeps the host warning treatment: the
|
||||
// whole row is replaced, exactly like a SystemWarningMessage row. The
|
||||
// rate-limit numbers come from the rate-limits atom, not the message.
|
||||
const rateLimitVariant: AIRateLimitWarningDescriptor["variant"] =
|
||||
isQuotaExhausted ? "messageLimitExceeded" : "rateLimitExceeded";
|
||||
const customWarning = isRateLimitError
|
||||
? renderWarning?.(
|
||||
getRateLimitWarningDescriptor(rateLimitVariant, rateLimits),
|
||||
{
|
||||
role: "system",
|
||||
id: message.id,
|
||||
createdAt: message.createdAt,
|
||||
variant: rateLimitVariant,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const retryActionLabel = !assistantError
|
||||
? t("ai.chat.actions.regenerate")
|
||||
: t("ai.chat.actions.retry");
|
||||
|
||||
const runningElapsedMs =
|
||||
status.kind === "streaming" ? liveNow - status.startedAt : undefined;
|
||||
const elapsedLabel = formatElapsedTime(
|
||||
status.kind === "streaming" ? runningElapsedMs : status.elapsedMs,
|
||||
);
|
||||
const stopReasonText =
|
||||
status.kind === "stopped"
|
||||
? t(`ai.chat.stopReason.${status.reason}`)
|
||||
: undefined;
|
||||
const truncationWarningText =
|
||||
status.kind === "done" && status.warning
|
||||
? t("ai.chat.status.truncatedResponse")
|
||||
: undefined;
|
||||
const activeProgressLabel =
|
||||
status.kind === "streaming" ? getStreamingProgressLabel(status, t) : null;
|
||||
const headerProgress = isStreaming ? (
|
||||
<div
|
||||
className="tta-chat-message__header-progress"
|
||||
aria-label={
|
||||
activeProgressLabel
|
||||
? `${activeProgressLabel} ${elapsedLabel ?? ""}`.trim()
|
||||
: elapsedLabel ?? undefined
|
||||
}
|
||||
title={activeProgressLabel ?? undefined}
|
||||
>
|
||||
<span className="tta-chat-message__progress-spinner" aria-hidden />
|
||||
{elapsedLabel && (
|
||||
<span className="tta-chat-message__progress-time">{elapsedLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
const statusLine = (() => {
|
||||
if (isStreaming) {
|
||||
if (!activeProgressLabel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tta-chat-message__progress tta-chat-message__progress--message"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="tta-chat-message__progress-label">
|
||||
{activeProgressLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const elapsedSummary = elapsedLabel
|
||||
? status.kind === "error"
|
||||
? t("ai.chat.status.failedAfter", { time: elapsedLabel })
|
||||
: status.kind === "stopped"
|
||||
? t("ai.chat.status.stoppedAfter", { time: elapsedLabel })
|
||||
: t("ai.chat.status.completedIn", { time: elapsedLabel })
|
||||
: undefined;
|
||||
|
||||
if (!stopReasonText && !elapsedSummary && !truncationWarningText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tta-chat-message__progress tta-chat-message__progress--complete">
|
||||
{truncationWarningText && (
|
||||
<span className="tta-chat-message__progress-label">
|
||||
{truncationWarningText}
|
||||
</span>
|
||||
)}
|
||||
{stopReasonText && (
|
||||
<span className="tta-chat-message__progress-label">
|
||||
{stopReasonText}
|
||||
</span>
|
||||
)}
|
||||
{elapsedSummary && (
|
||||
<span className="tta-chat-message__progress-time">
|
||||
{elapsedSummary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
if (customWarning !== undefined) {
|
||||
return <>{customWarning}</>;
|
||||
}
|
||||
|
||||
// --- Error node ---
|
||||
const errorNode = errorPresentation ? (
|
||||
<div
|
||||
className="tta-chat-message__error-details"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="tta-chat-message__error-message">{errorPresentation}</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// --- Actions ---
|
||||
const shouldShowActions = status.kind !== "streaming";
|
||||
|
||||
const actionsNode = shouldShowActions ? (
|
||||
<>
|
||||
{assistantOutputExists && (
|
||||
<ChatMessageActionButton
|
||||
icon={stackPushIcon}
|
||||
label="To canvas"
|
||||
onClick={onInsert}
|
||||
ariaLabel={t("ai.chat.actions.addToCanvas")}
|
||||
title={t("ai.chat.actions.addToCanvas")}
|
||||
disabled={!message.skeletons?.length}
|
||||
/>
|
||||
)}
|
||||
{(assistantError || showRetry) && (
|
||||
<ChatMessageActionButton
|
||||
icon={RetryIcon}
|
||||
label="Retry"
|
||||
onClick={onRetry}
|
||||
ariaLabel={retryActionLabel}
|
||||
title={retryActionLabel}
|
||||
disabled={isQuotaExhausted}
|
||||
/>
|
||||
)}
|
||||
{showDelete && (
|
||||
<ChatMessageActionButton
|
||||
icon={TrashIcon}
|
||||
onClick={onDelete}
|
||||
ariaLabel={t("ai.chat.actions.delete")}
|
||||
title={t("ai.chat.actions.delete")}
|
||||
danger
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
role="assistant"
|
||||
containerRef={messageContainerRef}
|
||||
roleLabel={t("ai.chat.roles.assistant")}
|
||||
timestamp={message.createdAt}
|
||||
content={visibleContent}
|
||||
isGenerating={isStreaming}
|
||||
previewSvg={assistantOutputExists ? previewSvg : undefined}
|
||||
onPreviewClick={handleGeneratedPreviewClick}
|
||||
onPreviewLoad={handleGeneratedPreviewLoad}
|
||||
previewLabel={t("ai.chat.preview")}
|
||||
error={errorNode}
|
||||
statusLine={statusLine}
|
||||
headerEnd={headerProgress}
|
||||
actions={actionsNode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TTAChatMessage: React.FC<TTAChatMessageProps> = (props) => {
|
||||
if (props.message.role === "assistant") {
|
||||
return <TTAAssistantChatMessage {...props} message={props.message} />;
|
||||
}
|
||||
|
||||
if (props.message.role === "system") {
|
||||
return (
|
||||
<TTASystemWarningMessage
|
||||
message={props.message}
|
||||
renderWarning={props.renderWarning}
|
||||
rateLimits={props.rateLimits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TTAUserChatMessage
|
||||
message={props.message}
|
||||
onPreview={props.onPreview}
|
||||
onRerun={props.onRerun}
|
||||
showRerun={props.showRerun}
|
||||
rateLimits={props.rateLimits}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,303 +0,0 @@
|
||||
@use "../css/variables.module" as *;
|
||||
|
||||
.tta-composer {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
border-top: 1px solid var(--sidebar-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.tta-composer__container {
|
||||
--tta-composer-shadow: 0px 18px 35px rgba(15, 23, 42, 0.12);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--island-bg-color);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.tta-composer__container:hover {
|
||||
border-color: var(--color-primary-light-darker);
|
||||
}
|
||||
|
||||
.tta-composer__container:focus-within {
|
||||
border-color: #6965db;
|
||||
}
|
||||
|
||||
.tta-composer__textarea-wrapper {
|
||||
position: relative;
|
||||
padding: 0 0 0.5rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tta-composer__textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none !important;
|
||||
background: var(--island-bg-color) !important;
|
||||
color: var(--text-primary-color);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
padding: 0 !important;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
overflow-wrap: anywhere;
|
||||
scrollbar-width: none;
|
||||
scrollbar-color: transparent transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-muted-darker);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible, :active) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(105, 101, 219, 0.18) transparent;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible, :active)::-webkit-scrollbar {
|
||||
width: 0.35rem;
|
||||
height: 0.35rem;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible, :active)::-webkit-scrollbar-thumb {
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.tta-composer__toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tta-composer__toolbar-section:last-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tta-composer__icon-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
color: var(--color-muted-darker);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-primary-light-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__image-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.tta-composer__plus-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.tta-composer__audio-btn {
|
||||
display: none;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__char-count {
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__send-btn {
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 8px;
|
||||
background-color: var(--sidebar-border-color);
|
||||
color: var(--color-muted-darker, #999);
|
||||
transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-muted-darker, #999);
|
||||
|
||||
.tta-composer__send-icon {
|
||||
color: var(--color-muted-darker, #999);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__send-btn--active {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-icon-white);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tta-composer__send-btn--active:hover {
|
||||
transform: scale(1.05) translateY(-1px);
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
.tta-composer__send-btn--active:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.tta-composer__send-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
transition: margin-left 0.2s ease;
|
||||
}
|
||||
|
||||
.tta-composer__send-icon--active {
|
||||
/* margin-left: 0.125rem; */ /* Removed for up arrow alignment */
|
||||
}
|
||||
|
||||
.tta-composer__stop-btn {
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 8px;
|
||||
background-color: var(--text-primary-color);
|
||||
color: var(--color-icon-white);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__stop-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tta-composer__container:focus-within .tta-composer__focus-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tta-composer__disclaimer {
|
||||
margin-top: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-darker);
|
||||
}
|
||||
|
||||
.tta-composer__preview {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tta-composer__preview-image-container {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
background: var(--default-bg-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover .tta-composer__preview-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-composer__preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tta-composer__preview-close {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
import { IMAGE_MIME_TYPES, KEYS, MIME_TYPES } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
ImageIcon,
|
||||
SendIcon,
|
||||
CloseIcon,
|
||||
playerStopFilledIcon,
|
||||
} from "../components/icons";
|
||||
import { generateIdFromFile, getDataURL, resizeImageFile } from "../data/blob";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
import { TTAWarningMessage } from "./TTAWarningMessage";
|
||||
import "./TTAComposer.scss";
|
||||
|
||||
export type TTAComposerImage = {
|
||||
hash: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
interface TTAComposerProps {
|
||||
onSend: (message: string, images?: string[]) => void;
|
||||
onStop?: () => void;
|
||||
isSending?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
images: TTAComposerImage[];
|
||||
onImagesChange: (images: TTAComposerImage[]) => void;
|
||||
maxImages: number;
|
||||
onMaxImages?: (maxImages: number) => React.ReactNode;
|
||||
placeholder?: string;
|
||||
onPreviewImage?: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const prepareImageAttachment = async (file: File) => {
|
||||
const resizedFile = await resizeImageFile(file, {
|
||||
outputType: MIME_TYPES.jpg,
|
||||
maxWidthOrHeight: 1024,
|
||||
});
|
||||
|
||||
return {
|
||||
hash: await generateIdFromFile(resizedFile),
|
||||
image: await getDataURL(resizedFile),
|
||||
};
|
||||
};
|
||||
|
||||
const TTAComposer: React.FC<TTAComposerProps> = ({
|
||||
onSend,
|
||||
onStop,
|
||||
isSending = false,
|
||||
value: inputValue,
|
||||
onChange,
|
||||
images: selectedImages,
|
||||
onImagesChange,
|
||||
maxImages: MAX_IMAGES,
|
||||
onMaxImages,
|
||||
placeholder,
|
||||
onPreviewImage,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [maxImagesWarning, setMaxImagesWarning] =
|
||||
useState<React.ReactNode>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// `appendImageFiles` reads the image list across `await`s, where the
|
||||
// `images` prop may be a render behind — the ref always holds the latest.
|
||||
const selectedImagesRef = useRef<readonly TTAComposerImage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedImagesRef.current = selectedImages;
|
||||
}, [selectedImages]);
|
||||
|
||||
const updateImages = useCallback(
|
||||
(next: TTAComposerImage[]) => {
|
||||
selectedImagesRef.current = next;
|
||||
onImagesChange(next);
|
||||
},
|
||||
[onImagesChange],
|
||||
);
|
||||
|
||||
const resetValue = useCallback(() => {
|
||||
onChange("");
|
||||
updateImages([]);
|
||||
setMaxImagesWarning(null);
|
||||
}, [onChange, updateImages]);
|
||||
|
||||
const showMaxImagesWarning = useCallback(() => {
|
||||
setMaxImagesWarning(
|
||||
onMaxImages
|
||||
? onMaxImages(MAX_IMAGES)
|
||||
: MAX_IMAGES === 1
|
||||
? t("ai.input.maxImages_one")
|
||||
: t("ai.input.maxImages", { maxImages: MAX_IMAGES }),
|
||||
);
|
||||
}, [MAX_IMAGES, onMaxImages, t]);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.style.height = "auto";
|
||||
const maxHeight = 240;
|
||||
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleInput();
|
||||
}, [handleInput, inputValue]);
|
||||
|
||||
const appendImageFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentImages = selectedImagesRef.current;
|
||||
const remainingSlots = MAX_IMAGES - currentImages.length;
|
||||
|
||||
if (remainingSlots <= 0) {
|
||||
showMaxImagesWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentImageHashes = new Set(
|
||||
currentImages.map((image) => image.hash),
|
||||
);
|
||||
const candidateHashes = new Set<string>();
|
||||
const imagesToAdd: TTAComposerImage[] = [];
|
||||
let hasUniqueOverflow = false;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { hash, image } = await prepareImageAttachment(file);
|
||||
|
||||
if (currentImageHashes.has(hash) || candidateHashes.has(hash)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (imagesToAdd.length >= remainingSlots) {
|
||||
hasUniqueOverflow = true;
|
||||
break;
|
||||
}
|
||||
|
||||
candidateHashes.add(hash);
|
||||
imagesToAdd.push({ hash, image });
|
||||
} catch (error) {
|
||||
console.error("Failed to process image:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!imagesToAdd.length) {
|
||||
if (hasUniqueOverflow) {
|
||||
showMaxImagesWarning();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateImages([...currentImages, ...imagesToAdd]);
|
||||
|
||||
if (hasUniqueOverflow) {
|
||||
showMaxImagesWarning();
|
||||
} else {
|
||||
setMaxImagesWarning(null);
|
||||
}
|
||||
},
|
||||
[MAX_IMAGES, showMaxImagesWarning, updateImages],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed && !selectedImages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSend(
|
||||
trimmed,
|
||||
selectedImages.length
|
||||
? selectedImages.map((image) => image.image)
|
||||
: undefined,
|
||||
);
|
||||
resetValue();
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [disabled, inputValue, selectedImages, onSend, resetValue]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === KEYS.ENTER &&
|
||||
!event.shiftKey &&
|
||||
// don't submit on IME composition confirm
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheelCapture = (event: React.WheelEvent<HTMLTextAreaElement>) => {
|
||||
event.stopPropagation();
|
||||
event.nativeEvent.stopImmediatePropagation?.();
|
||||
};
|
||||
|
||||
const handleOpenImagePicker = async () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedImagesRef.current.length >= MAX_IMAGES) {
|
||||
showMaxImagesWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await fileOpen({
|
||||
description: "Image",
|
||||
extensions: Object.keys(
|
||||
IMAGE_MIME_TYPES,
|
||||
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
||||
multiple: true,
|
||||
});
|
||||
await appendImageFiles(files);
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Failed to select image:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFiles = Array.from(items)
|
||||
.filter((item) => item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await appendImageFiles(imageFiles);
|
||||
},
|
||||
[appendImageFiles, disabled],
|
||||
);
|
||||
|
||||
const hasContent = inputValue.trim().length > 0 || selectedImages.length > 0;
|
||||
const canSend = hasContent && !isSending && !disabled;
|
||||
|
||||
return (
|
||||
<div className="tta-composer">
|
||||
{maxImagesWarning && (
|
||||
<TTAWarningMessage>{maxImagesWarning}</TTAWarningMessage>
|
||||
)}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="tta-composer__preview">
|
||||
{selectedImages.map((selectedImage, index) => (
|
||||
<div
|
||||
key={`${selectedImage.hash}-${index}`}
|
||||
className="tta-composer__preview-image-container"
|
||||
role={onPreviewImage ? "button" : undefined}
|
||||
tabIndex={onPreviewImage ? 0 : undefined}
|
||||
aria-label={
|
||||
onPreviewImage ? t("ai.chat.enlargedPreview") : undefined
|
||||
}
|
||||
onClick={() => onPreviewImage?.(selectedImage.image)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
onPreviewImage &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
onPreviewImage(selectedImage.image);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={selectedImage.image}
|
||||
alt={t("ai.chat.attachedImage")}
|
||||
className="tta-composer__preview-image"
|
||||
/>
|
||||
<button
|
||||
className="tta-composer__preview-close"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
updateImages(
|
||||
selectedImages.filter(
|
||||
(_, imageIndex) => imageIndex !== index,
|
||||
),
|
||||
);
|
||||
setMaxImagesWarning(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label={t("buttons.remove")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tta-composer__container">
|
||||
<div className="tta-composer__textarea-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onWheelCapture={handleWheelCapture}
|
||||
placeholder={placeholder || t("ai.input.placeholder")}
|
||||
rows={1}
|
||||
className="tta-composer__textarea"
|
||||
style={{ minHeight: "24px" }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tta-composer__toolbar">
|
||||
<div className="tta-composer__toolbar-section">
|
||||
<button
|
||||
type="button"
|
||||
className="tta-composer__icon-button tta-composer__icon-button--image"
|
||||
aria-label={t("ai.input.add")}
|
||||
onClick={handleOpenImagePicker}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="tta-composer__image-icon">{ImageIcon}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tta-composer__toolbar-section">
|
||||
{isSending && onStop ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="tta-composer__stop-btn"
|
||||
aria-label={t("ai.input.stop")}
|
||||
>
|
||||
<span className="tta-composer__stop-icon">
|
||||
{playerStopFilledIcon}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
className={`tta-composer__send-btn${
|
||||
canSend ? " tta-composer__send-btn--active" : ""
|
||||
}`}
|
||||
aria-label={t("ai.input.send")}
|
||||
>
|
||||
<span
|
||||
className={`tta-composer__send-icon${
|
||||
hasContent ? " tta-composer__send-icon--active" : ""
|
||||
}`}
|
||||
>
|
||||
{SendIcon}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTAComposer;
|
||||
@@ -1,634 +0,0 @@
|
||||
@import "../css/styles.scss";
|
||||
|
||||
// --- Floating Launcher & Button ---
|
||||
|
||||
.tta-floating-launcher {
|
||||
position: fixed;
|
||||
bottom: 4.5rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none;
|
||||
z-index: var(--zIndex-ui-tta-chat);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-floating-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tta-floating-button {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-floating-button {
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.65rem 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-icon-white);
|
||||
box-shadow: 0px 14px 30px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-floating-button__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
// --- Floating Panel Surface ---
|
||||
|
||||
.tta-panel__surface {
|
||||
pointer-events: auto;
|
||||
background: var(--default-bg-color, #fff);
|
||||
border-radius: var(--border-radius-lg);
|
||||
width: min(400px, calc(100vw - 2.5rem));
|
||||
height: min(720px, calc(100vh - 10rem));
|
||||
box-shadow: var(--shadow-island);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tta-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--sidebar-border-color);
|
||||
}
|
||||
|
||||
.tta-panel__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tta-panel__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tta-panel__icon-button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-primary-color);
|
||||
padding: 0rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-panel__new-chat-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tta-panel__new-chat-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tta-panel__icon-button--active {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-primary);
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-panel__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// --- Chat Panel Structure ---
|
||||
|
||||
.tta-chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: "Assistant", var(--ui-font, system-ui, sans-serif);
|
||||
}
|
||||
|
||||
.tta-chat-panel--floating {
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-island);
|
||||
background-color: var(--island-bg-color);
|
||||
}
|
||||
|
||||
// --- Chat Messages Area ---
|
||||
|
||||
.tta-chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tta-chat-list {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.75rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tta-chat-empty-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tta-chat-empty-middle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tta-chat-empty-intro {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.tta-chat-empty-top {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card {
|
||||
width: 100%;
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
background-color: transparent;
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.65rem;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-0.5px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(105, 101, 219, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card__preview {
|
||||
width: 88px;
|
||||
height: 62px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
background: var(--sidebar-bg-color);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--button-gray-1),
|
||||
var(--button-gray-2, var(--sidebar-border-color))
|
||||
);
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card__content {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card__title {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tta-chat-empty-resume-card__meta {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
.tta-chat-empty-how-it-works {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--sidebar-bg-color);
|
||||
padding: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.tta-chat-empty-how-it-works__title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.tta-chat-empty-how-it-works__steps {
|
||||
margin: 0.45rem 0 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
.tta-chat-empty-tagline {
|
||||
font-size: 0.9rem;
|
||||
padding: 0 4rem;
|
||||
color: var(--text-primary-color);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tta-panel__support-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-top: 1px solid var(--sidebar-border-color);
|
||||
background: var(--sidebar-bg-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tta-panel__support-banner-text {
|
||||
color: var(--text-secondary-color);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tta-panel__support-banner-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-panel__support-banner-dismiss {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0.8rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-panel__footer {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
// --- Preview Modal ---
|
||||
|
||||
.tta-preview-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.85rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__body {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
background: var(--sidebar-bg-color);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&__close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
|
||||
.tta-history {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
border-radius: inherit;
|
||||
background-color: var(--island-bg-color);
|
||||
padding: 0.75rem 0.75rem 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.tta-history__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tta-history__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tta-history__back-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-lg);
|
||||
height: 2.25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.tta-history__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tta-history__search {
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.85rem 0.6rem;
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
color: var(--text-primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.9rem;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:active {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tta-history__search-icon {
|
||||
font-size: 0.9rem;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tta-history__empty {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.tta-history__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-right: -0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.tta-history__item {
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: auto;
|
||||
position: relative;
|
||||
|
||||
@at-root .excalidraw.theme--light #{&} {
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(99, 102, 241, 0.05);
|
||||
cursor: pointer;
|
||||
|
||||
.tta-history__actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tta-history__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tta-history__header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.tta-history__actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tta-history__action-button--delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tta-history__edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tta-history__edit-actions .tta-panel__icon-button--cancel:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tta-history__item-title-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tta-history__rename-input {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
padding: 0 0.25rem !important;
|
||||
height: 1.75rem;
|
||||
background: transparent !important;
|
||||
color: var(--text-primary-color);
|
||||
min-width: 0;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:active {
|
||||
background: transparent !important;
|
||||
border-width: 1px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tta-history__item-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tta-history__thumb {
|
||||
width: 100%;
|
||||
height: 12rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-bottom: solid 1px var(--sidebar-border-color);
|
||||
padding-bottom: 0.75rem;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { useApp } from "../components/App";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useAppStateValue } from "../hooks/useAppStateValue";
|
||||
|
||||
import "./TTADialog.scss";
|
||||
import TTAComposer, { type TTAComposerImage } from "./TTAComposer";
|
||||
import { TTADialogTrigger } from "./TTADialogTrigger";
|
||||
import { TTAIndexedDBAdapter } from "./history";
|
||||
import {
|
||||
TTADialogPanel,
|
||||
type TTADialogPanelActions,
|
||||
type TTADialogPanelView,
|
||||
type TTAPreviewModalState,
|
||||
} from "./TTADialogPanel";
|
||||
|
||||
import { getTurnStartIndexForAssistantDelete } from "./chatHelpers";
|
||||
import { useCanvasDraft } from "./useCanvasDraft";
|
||||
import { useTTAChatActions } from "./useTTAChatActions";
|
||||
import { useTTAChatHistory } from "./useTTAChatHistory";
|
||||
|
||||
import type {
|
||||
ChatMessage,
|
||||
TTAChatScrollOptions,
|
||||
TTADialogRenderWelcomeScreen,
|
||||
TTADialogRenderWarning,
|
||||
TTAPersistenceAdapter,
|
||||
TTARateLimits,
|
||||
} from "./types";
|
||||
import type { TTATransportAdapter } from "./client";
|
||||
|
||||
// --- Constants & Helpers ---
|
||||
|
||||
const DEFAULT_MAX_IMAGES = 4;
|
||||
|
||||
// Atoms for state persistence across component mounts
|
||||
const ttaChatMessagesAtom = atom<ChatMessage[]>([]);
|
||||
const ttaRateLimitsAtom = atom<TTARateLimits | null>(null);
|
||||
|
||||
export interface TTADialogProps {
|
||||
maxImages?: number;
|
||||
onMaxImages?: (maxImages: number) => React.ReactNode;
|
||||
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
|
||||
renderWarning?: TTADialogRenderWarning;
|
||||
persistenceAdapter?: TTAPersistenceAdapter;
|
||||
transportAdapter: TTATransportAdapter;
|
||||
}
|
||||
|
||||
const TTADialogContent = ({
|
||||
maxImages = DEFAULT_MAX_IMAGES,
|
||||
onMaxImages,
|
||||
renderWelcomeScreen,
|
||||
renderWarning,
|
||||
persistenceAdapter = TTAIndexedDBAdapter,
|
||||
transportAdapter,
|
||||
}: TTADialogProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(true);
|
||||
const app = useApp();
|
||||
|
||||
const { t } = useI18n();
|
||||
const [chatMessages, setChatMessages] = useAtom(ttaChatMessagesAtom);
|
||||
const [rateLimits, setRateLimits] = useAtom(ttaRateLimitsAtom);
|
||||
const [composerInputValue, setComposerInputValue] = useState("");
|
||||
const [composerImages, setComposerImages] = useState<TTAComposerImage[]>([]);
|
||||
|
||||
// History Overlay State
|
||||
const [isHistoryVisible, setIsHistoryVisible] = useState(false);
|
||||
const history = useTTAChatHistory({
|
||||
chatMessages,
|
||||
persistenceAdapter,
|
||||
});
|
||||
const { chatHistory, latestHistoryChat, renameChat } = history;
|
||||
|
||||
const openSidebar = useAppStateValue("openSidebar");
|
||||
|
||||
const chatHistoryRef = useRef<HTMLDivElement>(null);
|
||||
const previousIsOpenRef = useRef(isOpen);
|
||||
const previousChatMessageCountRef = useRef(chatMessages.length);
|
||||
|
||||
const hasConversation = chatMessages.length > 0;
|
||||
const [previewModal, setPreviewModal] = useState<TTAPreviewModalState | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingDeleteMessageId, setPendingDeleteMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [pendingDeleteChatId, setPendingDeleteChatId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const pendingDeleteClearsConversation = useMemo(() => {
|
||||
if (!pendingDeleteMessageId) {
|
||||
return false;
|
||||
}
|
||||
const messageIndex = chatMessages.findIndex(
|
||||
(message) => message.id === pendingDeleteMessageId,
|
||||
);
|
||||
if (messageIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
if (chatMessages[messageIndex].role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getTurnStartIndexForAssistantDelete(chatMessages, messageIndex) === 0
|
||||
);
|
||||
}, [chatMessages, pendingDeleteMessageId]);
|
||||
const [dismissedSupportMessageId, setDismissedSupportMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const latestAssistantErrorMessageId = useMemo(() => {
|
||||
const latestMessage = chatMessages.at(-1);
|
||||
if (
|
||||
latestMessage &&
|
||||
latestMessage.role === "assistant" &&
|
||||
latestMessage.status.kind === "error"
|
||||
) {
|
||||
return latestMessage.id;
|
||||
}
|
||||
return null;
|
||||
}, [chatMessages]);
|
||||
const shouldShowSupportBanner = Boolean(
|
||||
latestAssistantErrorMessageId &&
|
||||
latestAssistantErrorMessageId !== dismissedSupportMessageId,
|
||||
);
|
||||
const dismissSupportBanner = useCallback(() => {
|
||||
if (latestAssistantErrorMessageId) {
|
||||
setDismissedSupportMessageId(latestAssistantErrorMessageId);
|
||||
}
|
||||
}, [latestAssistantErrorMessageId]);
|
||||
|
||||
const scrollChatToBottom = useCallback((options?: TTAChatScrollOptions) => {
|
||||
const element = chatHistoryRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
element.scrollHeight - element.clientHeight,
|
||||
);
|
||||
const keepElementTopVisible = options?.keepElementTopVisible;
|
||||
let nextScrollTop = maxScrollTop;
|
||||
|
||||
if (keepElementTopVisible) {
|
||||
const containerRect = element.getBoundingClientRect();
|
||||
const keepElementRect = keepElementTopVisible.getBoundingClientRect();
|
||||
const keepElementTopScroll =
|
||||
element.scrollTop + keepElementRect.top - containerRect.top;
|
||||
nextScrollTop = Math.min(maxScrollTop, Math.max(0, keepElementTopScroll));
|
||||
}
|
||||
|
||||
if (typeof element.scrollTo === "function") {
|
||||
element.scrollTo({
|
||||
top: nextScrollTop,
|
||||
...(options?.behavior &&
|
||||
typeof document !== "undefined" &&
|
||||
"scrollBehavior" in document.documentElement.style
|
||||
? { behavior: options.behavior }
|
||||
: null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
element.scrollTop = nextScrollTop;
|
||||
}, []);
|
||||
|
||||
const handleRateLimitInfo = useCallback(
|
||||
({
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
}: {
|
||||
rateLimit?: number | null;
|
||||
rateLimitRemaining?: number | null;
|
||||
}) => {
|
||||
if (
|
||||
typeof rateLimit !== "number" ||
|
||||
!Number.isFinite(rateLimit) ||
|
||||
typeof rateLimitRemaining !== "number" ||
|
||||
!Number.isFinite(rateLimitRemaining)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRateLimits({ rateLimit, rateLimitRemaining });
|
||||
},
|
||||
[setRateLimits],
|
||||
);
|
||||
|
||||
// --- Layout & Visibility Effects ---
|
||||
|
||||
const prevOpenSidebarNameRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const nextOpenSidebarName = openSidebar?.name ?? null;
|
||||
const prevOpenSidebarName = prevOpenSidebarNameRef.current;
|
||||
|
||||
// If the sidebar has just been opened and the AI chat isn't pinned,
|
||||
// close the floating panel to avoid overlapping UI.
|
||||
if (!prevOpenSidebarName && nextOpenSidebarName && !isPinned) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
prevOpenSidebarNameRef.current = nextOpenSidebarName;
|
||||
}, [openSidebar?.name, isPinned]);
|
||||
|
||||
const interactiveCanvas = app?.interactiveCanvas ?? null;
|
||||
const staticCanvas = app?.canvas ?? null;
|
||||
|
||||
// Close panel on canvas click (unless pinned)
|
||||
useEffect(() => {
|
||||
if (!isOpen || isPinned) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = interactiveCanvas ?? staticCanvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
canvas.removeEventListener("pointerdown", handlePointerDown);
|
||||
};
|
||||
}, [interactiveCanvas, staticCanvas, isOpen, isPinned]);
|
||||
|
||||
const handleClose = () => setIsOpen(false);
|
||||
const focusComposerInput = useCallback(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const input = document.querySelector<HTMLTextAreaElement>(
|
||||
"#tta-floating-panel .tta-composer__textarea",
|
||||
);
|
||||
input?.focus();
|
||||
}, []);
|
||||
const closePreviewModal = useCallback(() => {
|
||||
setPreviewModal(null);
|
||||
requestAnimationFrame(() => {
|
||||
focusComposerInput();
|
||||
});
|
||||
}, [focusComposerInput]);
|
||||
const openPreviewModal = useCallback(
|
||||
(
|
||||
image: string,
|
||||
titleKey: TTAPreviewModalState["titleKey"] = "ai.chat.generatedResult",
|
||||
) => {
|
||||
setPreviewModal({
|
||||
image,
|
||||
titleKey,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewModal) {
|
||||
return;
|
||||
}
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key !== "Escape" &&
|
||||
event.key !== "Esc" &&
|
||||
event.code !== "Escape"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
closePreviewModal();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscapeKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscapeKey, true);
|
||||
};
|
||||
}, [previewModal, closePreviewModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const handleTabToComposer = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
document.activeElement.closest(
|
||||
"#tta-floating-panel .tta-composer__textarea",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
focusComposerInput();
|
||||
};
|
||||
document.addEventListener("keydown", handleTabToComposer, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleTabToComposer, true);
|
||||
};
|
||||
}, [isOpen, focusComposerInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
focusComposerInput();
|
||||
});
|
||||
}
|
||||
}, [isOpen, focusComposerInput]);
|
||||
|
||||
// Scroll to the latest message when the panel (re)opens onto an existing
|
||||
// conversation and whenever a message is appended.
|
||||
useEffect(() => {
|
||||
const wasOpen = previousIsOpenRef.current;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
const previousMessageCount = previousChatMessageCountRef.current;
|
||||
previousChatMessageCountRef.current = chatMessages.length;
|
||||
|
||||
const didJustOpen = isOpen && !wasOpen && chatMessages.length > 0;
|
||||
const didAppendMessage = chatMessages.length > previousMessageCount;
|
||||
if (!didJustOpen && !didAppendMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
scrollChatToBottom();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [chatMessages.length, isOpen, scrollChatToBottom]);
|
||||
|
||||
// --- Chat Actions ---
|
||||
|
||||
// The single canvas-draft owner (tta_rewrite_final.md §2.4): streaming
|
||||
// renders, the commit dance, and the generation-replacement queue, keyed by
|
||||
// the local generation id (`message.id`).
|
||||
const canvasDraft = useCanvasDraft({ app });
|
||||
|
||||
const clearComposer = useCallback(() => {
|
||||
setComposerInputValue("");
|
||||
setComposerImages([]);
|
||||
}, []);
|
||||
|
||||
const hideHistory = useCallback(() => {
|
||||
setIsHistoryVisible(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isSendingChat,
|
||||
latestRetryableAssistantMessageId,
|
||||
sendChatPrompt,
|
||||
stopActiveGeneration,
|
||||
handleStartNewChat,
|
||||
handleSelectChat,
|
||||
handleDeleteChat,
|
||||
handleRetry,
|
||||
executeDelete,
|
||||
handleInsertResult,
|
||||
} = useTTAChatActions({
|
||||
app,
|
||||
t,
|
||||
chatMessages,
|
||||
setChatMessages,
|
||||
canvasDraft,
|
||||
transportAdapter,
|
||||
history,
|
||||
rateLimits,
|
||||
onRateLimitInfo: handleRateLimitInfo,
|
||||
isPanelOpen: isOpen,
|
||||
composerText: composerInputValue,
|
||||
clearComposer,
|
||||
hideHistory,
|
||||
scrollChatToBottom,
|
||||
focusComposerInput,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const handleNewChatShortcut = (event: KeyboardEvent) => {
|
||||
const isShortcutPressed =
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event.repeat &&
|
||||
event.key.toLowerCase() === "o";
|
||||
|
||||
if (
|
||||
!isShortcutPressed ||
|
||||
!hasConversation ||
|
||||
isSendingChat ||
|
||||
previewModal
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
handleStartNewChat();
|
||||
};
|
||||
document.addEventListener("keydown", handleNewChatShortcut, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleNewChatShortcut, true);
|
||||
};
|
||||
}, [
|
||||
hasConversation,
|
||||
handleStartNewChat,
|
||||
isOpen,
|
||||
isSendingChat,
|
||||
previewModal,
|
||||
]);
|
||||
|
||||
const hideHistoryAndFocusComposer = useCallback(() => {
|
||||
setIsHistoryVisible(false);
|
||||
requestAnimationFrame(() => {
|
||||
focusComposerInput();
|
||||
});
|
||||
}, [focusComposerInput]);
|
||||
|
||||
const requestDelete = useCallback((messageId: string) => {
|
||||
setPendingDeleteMessageId(messageId);
|
||||
}, []);
|
||||
|
||||
const cancelDelete = useCallback(() => {
|
||||
setPendingDeleteMessageId(null);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!pendingDeleteMessageId) {
|
||||
return;
|
||||
}
|
||||
const messageId = pendingDeleteMessageId;
|
||||
setPendingDeleteMessageId(null);
|
||||
executeDelete(messageId);
|
||||
}, [executeDelete, pendingDeleteMessageId]);
|
||||
|
||||
// History chat delete goes through the same confirm pattern as message
|
||||
// delete (tta_rewrite_final.md §8.2) — the delete itself is handled by the
|
||||
// actions hook (incl. auto-stopping an in-flight generation, N2).
|
||||
const requestDeleteChat = useCallback((chatId: string) => {
|
||||
setPendingDeleteChatId(chatId);
|
||||
}, []);
|
||||
|
||||
const cancelDeleteChat = useCallback(() => {
|
||||
setPendingDeleteChatId(null);
|
||||
}, []);
|
||||
|
||||
const confirmDeleteChat = useCallback(() => {
|
||||
if (!pendingDeleteChatId) {
|
||||
return;
|
||||
}
|
||||
const chatId = pendingDeleteChatId;
|
||||
setPendingDeleteChatId(null);
|
||||
handleDeleteChat(chatId);
|
||||
}, [handleDeleteChat, pendingDeleteChatId]);
|
||||
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
stopActiveGeneration("user");
|
||||
}, [stopActiveGeneration]);
|
||||
|
||||
// --- Render Sub-components ---
|
||||
|
||||
const composerJSX = (
|
||||
<TTAComposer
|
||||
onSend={(message, images) => sendChatPrompt(message, images)}
|
||||
onStop={handleStopGeneration}
|
||||
isSending={isSendingChat}
|
||||
value={composerInputValue}
|
||||
onChange={setComposerInputValue}
|
||||
images={composerImages}
|
||||
onImagesChange={setComposerImages}
|
||||
maxImages={maxImages}
|
||||
onMaxImages={onMaxImages}
|
||||
onPreviewImage={(image) =>
|
||||
openPreviewModal(image, "ai.chat.attachedImage")
|
||||
}
|
||||
placeholder={
|
||||
rateLimits?.rateLimitRemaining === 0
|
||||
? t("chat.rateLimit.messageLimitInputPlaceholder")
|
||||
: hasConversation
|
||||
? t("ai.input.placeholderRefine")
|
||||
: undefined
|
||||
}
|
||||
disabled={rateLimits?.rateLimitRemaining === 0}
|
||||
/>
|
||||
);
|
||||
|
||||
const panelView: TTADialogPanelView = {
|
||||
hasConversation,
|
||||
isSendingChat,
|
||||
isHistoryVisible,
|
||||
isPinned,
|
||||
shouldShowSupportBanner,
|
||||
composerInputValue,
|
||||
previewModal,
|
||||
chatMessages,
|
||||
chatHistory,
|
||||
latestHistoryChat,
|
||||
latestRetryableAssistantMessageId,
|
||||
composerImages,
|
||||
rateLimits,
|
||||
isConfirmDialogOpen: Boolean(pendingDeleteMessageId || pendingDeleteChatId),
|
||||
};
|
||||
|
||||
const panelActions: TTADialogPanelActions = {
|
||||
onStartNewChat: () => {
|
||||
handleStartNewChat();
|
||||
},
|
||||
onToggleHistory: () => {
|
||||
if (isHistoryVisible) {
|
||||
hideHistoryAndFocusComposer();
|
||||
return;
|
||||
}
|
||||
setIsHistoryVisible(true);
|
||||
},
|
||||
onTogglePinned: () => {
|
||||
setIsPinned((prev) => !prev);
|
||||
},
|
||||
onClose: handleClose,
|
||||
onClosePreviewModal: closePreviewModal,
|
||||
onOpenPreviewModal: openPreviewModal,
|
||||
onInsertResult: handleInsertResult,
|
||||
onRetry: (messageId) => {
|
||||
handleRetry(messageId);
|
||||
},
|
||||
// §5.8 re-run: a fresh turn with the same prompt and images — never a
|
||||
// retry payload (the orphaned turn has no completed attempt to target).
|
||||
onRerunMessage: (message) => {
|
||||
if (message.role === "user") {
|
||||
sendChatPrompt(message.content, message.images);
|
||||
}
|
||||
},
|
||||
onRequestDelete: requestDelete,
|
||||
scrollChatToBottom,
|
||||
onDismissSupportBanner: dismissSupportBanner,
|
||||
onSelectHistoryChat: handleSelectChat,
|
||||
onDeleteHistoryChat: requestDeleteChat,
|
||||
onRenameHistoryChat: renameChat,
|
||||
onHideHistory: hideHistoryAndFocusComposer,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TTADialogTrigger
|
||||
isOpen={isOpen}
|
||||
isPinned={isPinned}
|
||||
hasOpenSidebar={Boolean(openSidebar)}
|
||||
onToggle={() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<TTADialogPanel
|
||||
view={panelView}
|
||||
actions={panelActions}
|
||||
chatHistoryRef={chatHistoryRef}
|
||||
composer={composerJSX}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
renderWarning={renderWarning}
|
||||
/>
|
||||
</TTADialogTrigger>
|
||||
{pendingDeleteMessageId && (
|
||||
<ConfirmDialog
|
||||
title={t("ai.chat.actions.delete")}
|
||||
closeOnClickOutside
|
||||
onCancel={cancelDelete}
|
||||
onConfirm={confirmDelete}
|
||||
confirmText={t("ai.chat.actions.delete")}
|
||||
>
|
||||
<p>
|
||||
{pendingDeleteClearsConversation
|
||||
? t("ai.chat.actions.deletePromptFirstTurn")
|
||||
: t("ai.chat.actions.deletePrompt")}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
{pendingDeleteChatId && (
|
||||
<ConfirmDialog
|
||||
title={t("ai.chat.historyPanel.delete")}
|
||||
closeOnClickOutside
|
||||
onCancel={cancelDeleteChat}
|
||||
onConfirm={confirmDeleteChat}
|
||||
confirmText={t("ai.chat.historyPanel.delete")}
|
||||
>
|
||||
<p>{t("ai.chat.historyPanel.deletePrompt")}</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TTADialog = React.memo((props: TTADialogProps) => {
|
||||
const app = useApp();
|
||||
|
||||
if (app.props.aiEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TTADialogContent {...props} />;
|
||||
});
|
||||
TTADialog.displayName = "TTADialog";
|
||||
|
||||
export default TTADialog;
|
||||
@@ -1,151 +0,0 @@
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { EditorJotaiProvider } from "../editor-jotai";
|
||||
|
||||
import {
|
||||
TTADialogPanel,
|
||||
type TTADialogPanelActions,
|
||||
type TTADialogPanelView,
|
||||
} from "./TTADialogPanel";
|
||||
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
// TTAChatMessage renders assistant thumbnails through the preview hook —
|
||||
// irrelevant here, so stub it to the idle state.
|
||||
vi.mock("./useAIAssistantPreview", () => ({
|
||||
useAIAssistantPreview: () => ({ previewSvg: null, status: "idle" }),
|
||||
evictAssistantPreviews: vi.fn(),
|
||||
}));
|
||||
|
||||
const RERUN_LABEL = "Run again";
|
||||
|
||||
const user = (id: string, content: string, images?: string[]): ChatMessage => ({
|
||||
id,
|
||||
role: "user",
|
||||
content,
|
||||
images,
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const doneAssistant = (id: string): ChatMessage => ({
|
||||
id,
|
||||
role: "assistant",
|
||||
createdAt: 2,
|
||||
status: { kind: "done", elapsedMs: 10, outcome: "generated" },
|
||||
});
|
||||
|
||||
const createView = (
|
||||
overrides: Partial<TTADialogPanelView>,
|
||||
): TTADialogPanelView => ({
|
||||
hasConversation: true,
|
||||
isSendingChat: false,
|
||||
isHistoryVisible: false,
|
||||
isPinned: true,
|
||||
shouldShowSupportBanner: false,
|
||||
composerInputValue: "",
|
||||
composerImages: [],
|
||||
previewModal: null,
|
||||
chatMessages: [],
|
||||
chatHistory: [],
|
||||
latestHistoryChat: null,
|
||||
latestRetryableAssistantMessageId: null,
|
||||
rateLimits: null,
|
||||
isConfirmDialogOpen: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createActions = (): TTADialogPanelActions => ({
|
||||
onStartNewChat: vi.fn(),
|
||||
onToggleHistory: vi.fn(),
|
||||
onTogglePinned: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onClosePreviewModal: vi.fn(),
|
||||
onOpenPreviewModal: vi.fn(),
|
||||
onInsertResult: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
onRerunMessage: vi.fn(),
|
||||
onRequestDelete: vi.fn(),
|
||||
scrollChatToBottom: vi.fn(),
|
||||
onDismissSupportBanner: vi.fn(),
|
||||
onSelectHistoryChat: vi.fn(),
|
||||
onDeleteHistoryChat: vi.fn(),
|
||||
onRenameHistoryChat: vi.fn(),
|
||||
onHideHistory: vi.fn(),
|
||||
});
|
||||
|
||||
const renderPanel = (view: TTADialogPanelView) => {
|
||||
const actions = createActions();
|
||||
render(
|
||||
<EditorJotaiProvider>
|
||||
<TTADialogPanel
|
||||
view={view}
|
||||
actions={actions}
|
||||
chatHistoryRef={React.createRef<HTMLDivElement>()}
|
||||
composer={null}
|
||||
/>
|
||||
</EditorJotaiProvider>,
|
||||
);
|
||||
return actions;
|
||||
};
|
||||
|
||||
describe("TTADialogPanel re-run affordance (§5.8)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders on a trailing prompt-only user turn and re-runs that exact message", () => {
|
||||
const trailing = user("user-2", "add a dog", ["data:image/jpeg;base64,x"]);
|
||||
const actions = renderPanel(
|
||||
createView({
|
||||
chatMessages: [
|
||||
user("user-1", "draw a cat"),
|
||||
doneAssistant("assistant-1"),
|
||||
trailing,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const rerunButtons = screen.getAllByRole("button", { name: RERUN_LABEL });
|
||||
// exactly one — never on mid-list user turns
|
||||
expect(rerunButtons).toHaveLength(1);
|
||||
|
||||
fireEvent.click(rerunButtons[0]);
|
||||
expect(actions.onRerunMessage).toHaveBeenCalledTimes(1);
|
||||
expect(actions.onRerunMessage).toHaveBeenCalledWith(trailing);
|
||||
});
|
||||
|
||||
it("is hidden while a generation is in flight", () => {
|
||||
renderPanel(
|
||||
createView({
|
||||
isSendingChat: true,
|
||||
chatMessages: [
|
||||
user("user-1", "draw a cat"),
|
||||
doneAssistant("assistant-1"),
|
||||
user("user-2", "add a dog"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: RERUN_LABEL }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden when the trailing turn has an assistant reply", () => {
|
||||
renderPanel(
|
||||
createView({
|
||||
chatMessages: [
|
||||
user("user-1", "draw a cat"),
|
||||
doneAssistant("assistant-1"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: RERUN_LABEL }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,377 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { FilledButton } from "../components/FilledButton";
|
||||
import { CloseIcon, PinIcon, historyIcon } from "../components/icons";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
import { TTAChatEmptyState } from "./TTAChatEmptyState";
|
||||
import { TTAChatMessage } from "./TTAChatMessage";
|
||||
import { TTAHistory } from "./TTAHistory";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatConversation,
|
||||
TTAChatScrollOptions,
|
||||
TTADialogRenderWelcomeScreen,
|
||||
TTADialogRenderWarning,
|
||||
TTARateLimits,
|
||||
} from "./types";
|
||||
|
||||
const AI_SUPPORT_GITHUB_ISSUES_URL =
|
||||
"https://github.com/excalidraw/excalidraw/issues";
|
||||
const AI_SUPPORT_DISCORD_URL = "https://discord.gg/UexuTaE";
|
||||
|
||||
export type TTAPreviewModalState = {
|
||||
image: string;
|
||||
titleKey: "ai.chat.generatedResult" | "ai.chat.attachedImage";
|
||||
};
|
||||
|
||||
export type TTADialogPanelView = {
|
||||
hasConversation: boolean;
|
||||
isSendingChat: boolean;
|
||||
isHistoryVisible: boolean;
|
||||
isPinned: boolean;
|
||||
shouldShowSupportBanner: boolean;
|
||||
composerInputValue: string;
|
||||
composerImages: readonly unknown[];
|
||||
previewModal: TTAPreviewModalState | null;
|
||||
chatMessages: ChatMessage[];
|
||||
chatHistory: ChatConversation[];
|
||||
latestHistoryChat: ChatConversation | null;
|
||||
latestRetryableAssistantMessageId: string | null;
|
||||
rateLimits: TTARateLimits | null;
|
||||
/** A ConfirmDialog is open on top of the panel (suspends overlay Escape). */
|
||||
isConfirmDialogOpen: boolean;
|
||||
};
|
||||
|
||||
export type TTADialogPanelActions = {
|
||||
onStartNewChat: () => void;
|
||||
onToggleHistory: () => void;
|
||||
onTogglePinned: () => void;
|
||||
onClose: () => void;
|
||||
onClosePreviewModal: () => void;
|
||||
onOpenPreviewModal: (
|
||||
image: string,
|
||||
titleKey?: TTAPreviewModalState["titleKey"],
|
||||
) => void;
|
||||
onInsertResult: (message: ChatMessage) => void;
|
||||
onRetry: (messageId: string) => void;
|
||||
/** Re-runs a trailing prompt-only user turn as a fresh send (§5.8). */
|
||||
onRerunMessage: (message: ChatMessage) => void;
|
||||
onRequestDelete: (messageId: string) => void;
|
||||
scrollChatToBottom: (options?: TTAChatScrollOptions) => void;
|
||||
onDismissSupportBanner: () => void;
|
||||
onSelectHistoryChat: (chat: ChatConversation) => void;
|
||||
onDeleteHistoryChat: (chatId: string) => void;
|
||||
onRenameHistoryChat: (chatId: string, newTitle: string) => void;
|
||||
onHideHistory: () => void;
|
||||
};
|
||||
|
||||
export type TTADialogPanelProps = {
|
||||
view: TTADialogPanelView;
|
||||
actions: TTADialogPanelActions;
|
||||
chatHistoryRef: RefObject<HTMLDivElement | null>;
|
||||
composer: ReactNode;
|
||||
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
|
||||
renderWarning?: TTADialogRenderWarning;
|
||||
};
|
||||
|
||||
type TTADialogPanelHeaderProps = {
|
||||
hasConversation: boolean;
|
||||
isSendingChat: boolean;
|
||||
isHistoryVisible: boolean;
|
||||
isPinned: boolean;
|
||||
onStartNewChat: () => void;
|
||||
onToggleHistory: () => void;
|
||||
onTogglePinned: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const TTADialogPanelHeader = ({
|
||||
hasConversation,
|
||||
isSendingChat,
|
||||
isHistoryVisible,
|
||||
isPinned,
|
||||
onStartNewChat,
|
||||
onToggleHistory,
|
||||
onTogglePinned,
|
||||
onClose,
|
||||
}: TTADialogPanelHeaderProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="tta-panel__header">
|
||||
{hasConversation && (
|
||||
<FilledButton
|
||||
className=""
|
||||
onClick={onStartNewChat}
|
||||
disabled={isSendingChat}
|
||||
size="small"
|
||||
>
|
||||
{t("chat.newChat")}
|
||||
</FilledButton>
|
||||
)}
|
||||
<div className="tta-panel__header-actions">
|
||||
<Tooltip label={t("ai.chat.history")}>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__icon-button"
|
||||
onClick={onToggleHistory}
|
||||
aria-pressed={isHistoryVisible}
|
||||
aria-label={t("ai.chat.history")}
|
||||
>
|
||||
{historyIcon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("ai.chat.keepOpen")}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("tta-panel__icon-button ", {
|
||||
"tta-panel__icon-button--active": isPinned,
|
||||
})}
|
||||
onClick={onTogglePinned}
|
||||
aria-label={t("ai.chat.keepOpen")}
|
||||
aria-pressed={isPinned}
|
||||
>
|
||||
{PinIcon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("ai.chat.close")}>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__icon-button"
|
||||
onClick={onClose}
|
||||
aria-label={t("ai.chat.close")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TTADialogPreviewModal = ({
|
||||
previewModal,
|
||||
onClosePreviewModal,
|
||||
}: {
|
||||
previewModal: TTAPreviewModalState | null;
|
||||
onClosePreviewModal: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!previewModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
labelledBy="tta-preview-modal-title"
|
||||
maxWidth={720}
|
||||
onCloseRequest={onClosePreviewModal}
|
||||
>
|
||||
<div className="tta-preview-modal">
|
||||
<div className="tta-preview-modal__header">
|
||||
<h2 id="tta-preview-modal-title" className="tta-preview-modal__title">
|
||||
{t(previewModal.titleKey)}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-preview-modal__close"
|
||||
onClick={onClosePreviewModal}
|
||||
aria-label={t("ai.chat.closePreview")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tta-preview-modal__body">
|
||||
<img src={previewModal.image} alt={t("ai.chat.enlargedPreview")} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const TTADialogPanelBody = ({
|
||||
view,
|
||||
actions,
|
||||
chatHistoryRef,
|
||||
renderWelcomeScreen,
|
||||
renderWarning,
|
||||
}: {
|
||||
view: Pick<
|
||||
TTADialogPanelView,
|
||||
| "composerInputValue"
|
||||
| "composerImages"
|
||||
| "chatMessages"
|
||||
| "isSendingChat"
|
||||
| "latestHistoryChat"
|
||||
| "latestRetryableAssistantMessageId"
|
||||
| "rateLimits"
|
||||
>;
|
||||
actions: Pick<
|
||||
TTADialogPanelActions,
|
||||
| "onSelectHistoryChat"
|
||||
| "onInsertResult"
|
||||
| "onRetry"
|
||||
| "onRerunMessage"
|
||||
| "onRequestDelete"
|
||||
| "onOpenPreviewModal"
|
||||
| "scrollChatToBottom"
|
||||
>;
|
||||
chatHistoryRef: RefObject<HTMLDivElement | null>;
|
||||
renderWelcomeScreen?: TTADialogRenderWelcomeScreen;
|
||||
renderWarning?: TTADialogRenderWarning;
|
||||
}) => (
|
||||
<div className="tta-panel__body">
|
||||
<div className="tta-chat-container">
|
||||
{!view.chatMessages.length &&
|
||||
!view.composerInputValue &&
|
||||
!view.composerImages.length && (
|
||||
<TTAChatEmptyState
|
||||
latestHistoryChat={view.latestHistoryChat}
|
||||
onSelectHistoryChat={actions.onSelectHistoryChat}
|
||||
rateLimits={view.rateLimits}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
)}
|
||||
{!!view.chatMessages.length && (
|
||||
<div className="tta-chat-list" ref={chatHistoryRef}>
|
||||
{view.chatMessages.map((message, index) => (
|
||||
<TTAChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
onInsert={() => actions.onInsertResult(message)}
|
||||
onRetry={() => actions.onRetry(message.id)}
|
||||
showRetry={
|
||||
message.role === "assistant" &&
|
||||
message.id === view.latestRetryableAssistantMessageId
|
||||
}
|
||||
onRerun={() => actions.onRerunMessage(message)}
|
||||
showRerun={
|
||||
// §5.8 re-run affordance: a trailing user message with no
|
||||
// assistant/system reply (reload or switch-away
|
||||
// mid-generation) can be run again as a fresh turn — hidden
|
||||
// whenever a generation is in flight (single-flight, so also
|
||||
// while another chat's generation streams in the background).
|
||||
message.role === "user" &&
|
||||
index === view.chatMessages.length - 1 &&
|
||||
!view.isSendingChat
|
||||
}
|
||||
rateLimits={view.rateLimits}
|
||||
onDelete={() => actions.onRequestDelete(message.id)}
|
||||
showDelete={message.role === "assistant"}
|
||||
onPreview={(image) =>
|
||||
actions.onOpenPreviewModal(
|
||||
image,
|
||||
message.role === "user"
|
||||
? "ai.chat.attachedImage"
|
||||
: "ai.chat.generatedResult",
|
||||
)
|
||||
}
|
||||
scrollChatToBottom={actions.scrollChatToBottom}
|
||||
renderWarning={renderWarning}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TTADialogSupportBanner = ({
|
||||
onDismissSupportBanner,
|
||||
}: {
|
||||
onDismissSupportBanner: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="tta-panel__support-banner">
|
||||
<span className="tta-panel__support-banner-text">
|
||||
{t("ai.chat.errors.supportBanner")}
|
||||
</span>
|
||||
<div className="tta-panel__support-banner-links">
|
||||
<a href={AI_SUPPORT_GITHUB_ISSUES_URL} target="_blank" rel="noreferrer">
|
||||
{t("ai.chat.errors.githubIssue")}
|
||||
</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a href={AI_SUPPORT_DISCORD_URL} target="_blank" rel="noreferrer">
|
||||
{t("labels.discordChat")}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__support-banner-dismiss"
|
||||
onClick={onDismissSupportBanner}
|
||||
aria-label={t("ai.chat.close")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TTADialogPanel = ({
|
||||
view,
|
||||
actions,
|
||||
chatHistoryRef,
|
||||
composer,
|
||||
renderWelcomeScreen,
|
||||
renderWarning,
|
||||
}: TTADialogPanelProps) => (
|
||||
<div
|
||||
className="tta-panel__surface"
|
||||
id="tta-floating-panel"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className={clsx("tta-chat-panel", "tta-chat-panel--floating")}>
|
||||
<TTADialogPanelHeader
|
||||
hasConversation={view.hasConversation}
|
||||
isSendingChat={view.isSendingChat}
|
||||
isHistoryVisible={view.isHistoryVisible}
|
||||
isPinned={view.isPinned}
|
||||
onStartNewChat={actions.onStartNewChat}
|
||||
onToggleHistory={actions.onToggleHistory}
|
||||
onTogglePinned={actions.onTogglePinned}
|
||||
onClose={actions.onClose}
|
||||
/>
|
||||
|
||||
<TTADialogPreviewModal
|
||||
previewModal={view.previewModal}
|
||||
onClosePreviewModal={actions.onClosePreviewModal}
|
||||
/>
|
||||
|
||||
<TTADialogPanelBody
|
||||
view={view}
|
||||
actions={actions}
|
||||
chatHistoryRef={chatHistoryRef}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
renderWarning={renderWarning}
|
||||
/>
|
||||
|
||||
{view.shouldShowSupportBanner && (
|
||||
<TTADialogSupportBanner
|
||||
onDismissSupportBanner={actions.onDismissSupportBanner}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="tta-panel__footer">{composer}</div>
|
||||
|
||||
{view.isHistoryVisible && (
|
||||
<TTAHistory
|
||||
history={view.chatHistory}
|
||||
onSelectChat={actions.onSelectHistoryChat}
|
||||
onDeleteChat={actions.onDeleteHistoryChat}
|
||||
onRenameChat={actions.onRenameHistoryChat}
|
||||
onClose={actions.onHideHistory}
|
||||
suspendEscape={view.isConfirmDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,134 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useAtomValue } from "../editor-jotai";
|
||||
import { MagicIconThin } from "../components/icons";
|
||||
import { isSidebarDockedAtom } from "../components/Sidebar/Sidebar";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type TTADialogTriggerProps = {
|
||||
isOpen: boolean;
|
||||
isPinned: boolean;
|
||||
hasOpenSidebar: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const TTADialogTrigger = ({
|
||||
isOpen,
|
||||
isPinned,
|
||||
hasOpenSidebar,
|
||||
onToggle,
|
||||
children,
|
||||
}: TTADialogTriggerProps) => {
|
||||
const { TTADialogTriggerTunnel } = useTunnels();
|
||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
||||
const launcherRef = useRef<HTMLDivElement>(null);
|
||||
const [sidebarShiftPx, setSidebarShiftPx] = useState(0);
|
||||
const [triggerShiftPx, setTriggerShiftPx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const needsPanelShift = isOpen && isPinned && hasOpenSidebar;
|
||||
const needsTriggerShift = hasOpenSidebar && !isSidebarDocked;
|
||||
|
||||
if (!needsPanelShift && !needsTriggerShift) {
|
||||
setSidebarShiftPx(0);
|
||||
setTriggerShiftPx(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const sidebarEl = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebarEl) {
|
||||
setSidebarShiftPx(0);
|
||||
setTriggerShiftPx(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateShift = () => {
|
||||
const sidebarRect = sidebarEl.getBoundingClientRect();
|
||||
const sidebarWidth = sidebarRect.width;
|
||||
const remPx =
|
||||
parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
|
||||
if (needsPanelShift) {
|
||||
// `.tta-floating-launcher` uses `right: 1rem` by default, so subtract it
|
||||
// then add 1rem gap so the panel doesn't sit flush against the sidebar.
|
||||
const launcherRight =
|
||||
parseFloat(
|
||||
getComputedStyle(launcherRef.current ?? sidebarEl).right,
|
||||
) || 0;
|
||||
|
||||
setSidebarShiftPx(Math.max(0, sidebarWidth - launcherRight + remPx));
|
||||
} else {
|
||||
setSidebarShiftPx(0);
|
||||
}
|
||||
|
||||
if (needsTriggerShift) {
|
||||
// When sidebar is undocked (floating), position the trigger button
|
||||
// with the same gap from the sidebar as the dialog panel.
|
||||
setTriggerShiftPx(sidebarWidth + remPx);
|
||||
} else {
|
||||
setTriggerShiftPx(0);
|
||||
}
|
||||
};
|
||||
|
||||
updateShift();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(updateShift);
|
||||
resizeObserver?.observe(sidebarEl);
|
||||
|
||||
window.addEventListener("resize", updateShift);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", updateShift);
|
||||
};
|
||||
}, [hasOpenSidebar, isOpen, isPinned, isSidebarDocked]);
|
||||
|
||||
const launcherStyle = {
|
||||
transform: sidebarShiftPx ? `translateX(-${sidebarShiftPx}px)` : undefined,
|
||||
} as const;
|
||||
|
||||
const triggerStyle = triggerShiftPx
|
||||
? ({
|
||||
position: "fixed" as const,
|
||||
bottom: "1rem",
|
||||
right: `${triggerShiftPx}px`,
|
||||
zIndex: 100,
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TTADialogTriggerTunnel.In>
|
||||
<div className="tta-floating-inline" style={triggerStyle}>
|
||||
<button
|
||||
className={clsx("tta-floating-button", {
|
||||
"tta-floating-button--active": isOpen,
|
||||
})}
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="tta-floating-panel"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="tta-floating-button__icon">{MagicIconThin}</span>
|
||||
<span>Generate</span>
|
||||
</button>
|
||||
</div>
|
||||
</TTADialogTriggerTunnel.In>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={launcherRef}
|
||||
className="tta-floating-launcher"
|
||||
style={launcherStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,386 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
CloseIcon,
|
||||
searchIcon,
|
||||
checkIcon,
|
||||
FreedrawIcon,
|
||||
TrashIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import "./TTADialog.scss";
|
||||
|
||||
import { TTA_CHAT_EMPTY_PLACEHOLDER_SRC } from "./assets";
|
||||
import {
|
||||
compareConversationsByUpdatedAt,
|
||||
getConversationPreviewMessage,
|
||||
} from "./chatHelpers";
|
||||
import { useAIAssistantPreview } from "./useAIAssistantPreview";
|
||||
|
||||
import type { AssistantMessage, ChatConversation } from "./types";
|
||||
|
||||
export interface TTAHistoryProps {
|
||||
history: ChatConversation[];
|
||||
onSelectChat: (chat: ChatConversation) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
onRenameChat: (chatId: string, newTitle: string) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Suspends the overlay's window-capture Escape handling while a modal sits
|
||||
* on top of it (the chat-delete ConfirmDialog) — that capture listener runs
|
||||
* before any handler of the modal itself, so without this the overlay would
|
||||
* close behind the modal on Escape.
|
||||
*/
|
||||
suspendEscape?: boolean;
|
||||
}
|
||||
|
||||
const TTAHistoryThumbnailPlaceholder = () => (
|
||||
<div className="tta-history__thumb">
|
||||
<img src={TTA_CHAT_EMPTY_PLACEHOLDER_SRC} alt="" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const TTAHistoryThumbnail = ({
|
||||
chatId,
|
||||
isVisible,
|
||||
message,
|
||||
}: {
|
||||
chatId: string;
|
||||
isVisible: boolean;
|
||||
message: AssistantMessage;
|
||||
}) => {
|
||||
const { previewSvg } = useAIAssistantPreview(message, {
|
||||
enabled: isVisible,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="tta-history__thumb" data-chat-id={chatId}>
|
||||
<img src={previewSvg || TTA_CHAT_EMPTY_PLACEHOLDER_SRC} alt="" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TTAHistory: React.FC<TTAHistoryProps> = ({
|
||||
history,
|
||||
onSelectChat,
|
||||
onDeleteChat,
|
||||
onRenameChat,
|
||||
onClose,
|
||||
suspendEscape = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const historyListRef = useRef<HTMLDivElement>(null);
|
||||
const [visiblePreviewChatIds, setVisiblePreviewChatIds] = useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
const visiblePreviewChatIdsRef = useRef<Set<string>>(new Set());
|
||||
const [historySearch, setHistorySearch] = useState("");
|
||||
const [editingChatId, setEditingChatId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Handle Escape key to close history or cancel editing
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (suspendEscape) {
|
||||
// a modal on top of the overlay owns Escape right now
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingChatId) {
|
||||
setEditingChatId(null);
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (historySearch) {
|
||||
setHistorySearch("");
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [editingChatId, historySearch, onClose, suspendEscape]);
|
||||
|
||||
const handleStartRename = (chat: ChatConversation, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingChatId(chat.id);
|
||||
setEditingTitle(chat.title);
|
||||
};
|
||||
|
||||
const handleConfirmRename = (chatId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (editingTitle.trim()) {
|
||||
onRenameChat(chatId, editingTitle.trim());
|
||||
}
|
||||
setEditingChatId(null);
|
||||
setEditingTitle("");
|
||||
};
|
||||
|
||||
const handleCancelRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingChatId(null);
|
||||
setEditingTitle("");
|
||||
};
|
||||
|
||||
const normalizedSearch = historySearch.trim().toLowerCase();
|
||||
const filteredHistory = useMemo(
|
||||
() =>
|
||||
history
|
||||
.filter((item) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
item.title.toLowerCase().includes(normalizedSearch) ||
|
||||
item.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
message.content.toLowerCase().includes(normalizedSearch),
|
||||
)
|
||||
);
|
||||
})
|
||||
.sort(compareConversationsByUpdatedAt),
|
||||
[history, normalizedSearch],
|
||||
);
|
||||
|
||||
const markPreviewChatIdsVisible = useCallback((chatIds: string[]) => {
|
||||
if (!chatIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVisiblePreviewChatIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
let didChange = false;
|
||||
for (const chatId of chatIds) {
|
||||
if (next.has(chatId)) {
|
||||
continue;
|
||||
}
|
||||
next.add(chatId);
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
visiblePreviewChatIdsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const historyList = historyListRef.current;
|
||||
if (!historyList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnailElements = Array.from(
|
||||
historyList.querySelectorAll<HTMLElement>(
|
||||
".tta-history__thumb[data-chat-id]",
|
||||
),
|
||||
);
|
||||
if (!thumbnailElements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
markPreviewChatIdsVisible(
|
||||
thumbnailElements
|
||||
.map((element) => element.dataset.chatId)
|
||||
.filter((chatId): chatId is string => Boolean(chatId)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const intersectingChatIds: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = entry.target as HTMLElement;
|
||||
const chatId = target.dataset.chatId;
|
||||
if (!chatId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
intersectingChatIds.push(chatId);
|
||||
observer.unobserve(target);
|
||||
}
|
||||
|
||||
markPreviewChatIdsVisible(intersectingChatIds);
|
||||
},
|
||||
{
|
||||
root: historyList,
|
||||
rootMargin: "160px 0px",
|
||||
},
|
||||
);
|
||||
|
||||
for (const element of thumbnailElements) {
|
||||
const chatId = element.dataset.chatId;
|
||||
if (!chatId || visiblePreviewChatIdsRef.current.has(chatId)) {
|
||||
continue;
|
||||
}
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [filteredHistory, markPreviewChatIdsVisible]);
|
||||
|
||||
return (
|
||||
<div className="tta-history">
|
||||
<div className="tta-history__header">
|
||||
<div className="tta-history__title">
|
||||
{t("ai.chat.historyPanel.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-history__back-button"
|
||||
onClick={onClose}
|
||||
aria-label={t("ai.chat.historyPanel.back")}
|
||||
>
|
||||
{t("ai.chat.historyPanel.back")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tta-history__controls">
|
||||
<div className="tta-history__search">
|
||||
<span className="tta-history__search-icon" aria-hidden="true">
|
||||
{searchIcon}
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
placeholder={t("ai.chat.historyPanel.searchPlaceholder")}
|
||||
value={historySearch}
|
||||
onChange={(event) => setHistorySearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredHistory.length === 0 ? (
|
||||
<div className="tta-history__empty">
|
||||
{t("ai.chat.historyPanel.noChats")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tta-history__list" ref={historyListRef}>
|
||||
{filteredHistory.map((item) => {
|
||||
const isEditing = editingChatId === item.id;
|
||||
const previewMessage = getConversationPreviewMessage(item.messages);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="tta-history__item"
|
||||
onClick={() => !isEditing && onSelectChat(item)}
|
||||
>
|
||||
{previewMessage ? (
|
||||
<TTAHistoryThumbnail
|
||||
chatId={item.id}
|
||||
isVisible={visiblePreviewChatIds.has(item.id)}
|
||||
message={previewMessage}
|
||||
/>
|
||||
) : (
|
||||
<TTAHistoryThumbnailPlaceholder />
|
||||
)}
|
||||
<div className="tta-history__details">
|
||||
<div className="tta-history__header-row">
|
||||
{isEditing ? (
|
||||
<div className="tta-history__item-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
value={editingTitle}
|
||||
// titles derive at 80 chars (getConversationTitle)
|
||||
maxLength={80}
|
||||
onChange={(e) => setEditingTitle(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
handleConfirmRename(item.id);
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setEditingChatId(null);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="tta-history__rename-input"
|
||||
/>
|
||||
<div className="tta-history__edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__icon-button tta-panel__icon-button--confirm"
|
||||
onClick={(e) => handleConfirmRename(item.id, e)}
|
||||
title={t("ai.chat.historyPanel.confirm")}
|
||||
>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__icon-button tta-panel__icon-button--cancel"
|
||||
onClick={handleCancelRename}
|
||||
title={t("ai.chat.historyPanel.cancel")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tta-history__item-title">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="tta-history__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__icon-button"
|
||||
onClick={(e) => handleStartRename(item, e)}
|
||||
title={t("ai.chat.historyPanel.rename")}
|
||||
>
|
||||
{FreedrawIcon}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tta-panel__icon-button tta-history__action-button--delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteChat(item.id);
|
||||
}}
|
||||
title={t("ai.chat.historyPanel.delete")}
|
||||
>
|
||||
{TrashIcon}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
.tta-warning-message {
|
||||
width: min(100%, 21rem);
|
||||
margin: 0.5rem auto 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--color-warning-dark);
|
||||
border-radius: 8px;
|
||||
background: var(--color-warning);
|
||||
color: var(--color-warning-color);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import "./TTAWarningMessage.scss";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const TTAWarningMessage = ({ children }: { children: ReactNode }) => {
|
||||
if (children === null || children === undefined || children === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tta-warning-message" role="alert" aria-live="polite">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
// Inlined as a data URI so npm consumers don't need to host the asset at a
|
||||
// well-known path (it used to be referenced as root-absolute
|
||||
// `/tta-chat-empty.svg`, which only worked on excalidraw.com).
|
||||
// Source: `public/tta-chat-empty.svg` (repo root).
|
||||
export const TTA_CHAT_EMPTY_PLACEHOLDER_SRC = `data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB3aWR0aD0iMzIwIgogIGhlaWdodD0iMTgwIgogIHZpZXdCb3g9IjAgMCAzMjAgMTgwIgogIGZpbGw9Im5vbmUiCj4KICA8cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjE4MCIgcng9IjE4IiBmaWxsPSIjRjhGQUZDIiAvPgogIDxyZWN0IHg9IjIwIiB5PSIyMCIgd2lkdGg9IjI4MCIgaGVpZ2h0PSIxNDAiIHJ4PSIxNCIgZmlsbD0iI0ZGRkZGRiIgLz4KICA8cmVjdCB4PSI0NCIgeT0iNDQiIHdpZHRoPSI5NCIgaGVpZ2h0PSI1OCIgcng9IjEwIiBmaWxsPSIjRTJFOEYwIiAvPgogIDxyZWN0IHg9IjE1OCIgeT0iNDgiIHdpZHRoPSIxMTgiIGhlaWdodD0iMTIiIHJ4PSI2IiBmaWxsPSIjQ0JENUUxIiAvPgogIDxyZWN0IHg9IjE1OCIgeT0iNjgiIHdpZHRoPSI5MiIgaGVpZ2h0PSIxMCIgcng9IjUiIGZpbGw9IiNFMkU4RjAiIC8+CiAgPHJlY3QgeD0iNDQiIHk9IjEyMCIgd2lkdGg9IjIzMiIgaGVpZ2h0PSIxNCIgcng9IjciIGZpbGw9IiNFMkU4RjAiIC8+CiAgPHBhdGgKICAgIGQ9Ik0yMzAgMTAxYzAtOC44MzcgNy4xNjMtMTYgMTYtMTZzMTYgNy4xNjMgMTYgMTYtNy4xNjMgMTYtMTYgMTYtMTYtNy4xNjMtMTYtMTZaIgogICAgZmlsbD0iI0VFRjJGRiIKICAvPgogIDxwYXRoCiAgICBkPSJNMjQ2IDkxLjVsMi43ODYgNS42NDggNi4yMTQuOTAzLTQuNSA0LjM4NyAxLjA2MiA2LjE4OEwyNDYgMTA1Ljc1bC01LjU2MiAyLjg3NiAxLjA2Mi02LjE4OC00LjUtNC4zODcgNi4yMTQtLjkwM0wyNDYgOTEuNVoiCiAgICBmaWxsPSIjNjM2NkYxIgogIC8+CiAgPGNpcmNsZSBjeD0iOTQiIGN5PSI3MyIgcj0iMTgiIGZpbGw9IiNEQkVBRkUiIC8+CiAgPHBhdGgKICAgIGQ9Ik04MyA3M2w3IDcgMTUtMTYiCiAgICBzdHJva2U9IiMyNTYzRUIiCiAgICBzdHJva2UtbGluZWNhcD0icm91bmQiCiAgICBzdHJva2UtbGluZWpvaW49InJvdW5kIgogICAgc3Ryb2tlLXdpZHRoPSI1IgogIC8+Cjwvc3ZnPgo=`;
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { AI_ERROR_CODE } from "./types";
|
||||
|
||||
export type AIChatRuntimeError = Error & {
|
||||
code?: AI_ERROR_CODE | number;
|
||||
handled?: boolean;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
export const withAIChatErrorMeta = (
|
||||
error: Error,
|
||||
options?: {
|
||||
code?: AI_ERROR_CODE | number;
|
||||
handled?: boolean;
|
||||
cause?: unknown;
|
||||
},
|
||||
): AIChatRuntimeError => {
|
||||
const runtimeError = error as AIChatRuntimeError;
|
||||
if (options && "code" in options && typeof options.code === "number") {
|
||||
runtimeError.code = options.code;
|
||||
}
|
||||
if (options && "handled" in options && typeof options.handled === "boolean") {
|
||||
runtimeError.handled = options.handled;
|
||||
}
|
||||
if (options && "cause" in options) {
|
||||
runtimeError.cause = options.cause;
|
||||
}
|
||||
return runtimeError;
|
||||
};
|
||||
|
||||
export const getAIChatErrorCode = (
|
||||
error: unknown,
|
||||
): AI_ERROR_CODE | number | undefined => {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const maybeCode = (error as { code?: unknown }).code;
|
||||
return typeof maybeCode === "number" ? maybeCode : undefined;
|
||||
};
|
||||
|
||||
export const isAIChatErrorHandled = (error: unknown) => {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (error as { handled?: unknown }).handled === true;
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { AssistantMessage, ChatConversation, ChatMessage } from "./types";
|
||||
|
||||
export const compareConversationsByUpdatedAt = (
|
||||
a: Pick<ChatConversation, "updatedAt">,
|
||||
b: Pick<ChatConversation, "updatedAt">,
|
||||
) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
|
||||
|
||||
/**
|
||||
* Marks generations that were still streaming (e.g. when a chat is re-opened
|
||||
* after an interrupted session) as stopped/interrupted.
|
||||
*/
|
||||
export const stopIncompleteAssistantMessages = (
|
||||
messages: ChatMessage[],
|
||||
): ChatMessage[] =>
|
||||
messages.map((message) =>
|
||||
message.role === "assistant" && message.status.kind === "streaming"
|
||||
? {
|
||||
...message,
|
||||
status: {
|
||||
kind: "stopped",
|
||||
elapsedMs: Math.max(0, Date.now() - message.status.startedAt),
|
||||
reason: "interrupted",
|
||||
},
|
||||
}
|
||||
: message,
|
||||
);
|
||||
|
||||
/**
|
||||
* Local generation id of the latest assistant generation with drawable
|
||||
* content — the canvas tag (`customData[AI_GENERATED_ELEMENTS_KEY]`) of the
|
||||
* conversation's current on-canvas result. Only assistants with skeletons
|
||||
* produce tags: a generation that never rendered has nothing on the canvas to
|
||||
* replace.
|
||||
*/
|
||||
export const getLatestAssistantGenerationId = (
|
||||
messages: ChatMessage[],
|
||||
): string | null => {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message.role === "assistant" && message.skeletons?.length) {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getLatestRetryableAssistantMessage = (
|
||||
messages: ChatMessage[],
|
||||
): AssistantMessage | null => {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message.role === "assistant" && message.status.kind !== "streaming") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Canvas tags (= local generation ids) of every assistant generation with
|
||||
* drawable content in the conversation.
|
||||
*/
|
||||
export const getAssistantGenerationTags = (
|
||||
messages: ChatMessage[],
|
||||
): string[] => {
|
||||
const generationTags: string[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.skeletons?.length) {
|
||||
generationTags.push(message.id);
|
||||
}
|
||||
}
|
||||
return generationTags;
|
||||
};
|
||||
|
||||
export const getTurnStartIndexForAssistantDelete = (
|
||||
messages: ChatMessage[],
|
||||
messageIndex: number,
|
||||
) => {
|
||||
let turnStartIndex = messageIndex;
|
||||
for (let index = messageIndex - 1; index >= 0; index--) {
|
||||
if (messages[index].role === "user") {
|
||||
turnStartIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return turnStartIndex;
|
||||
};
|
||||
|
||||
export const getConversationTitle = (
|
||||
messages: ChatMessage[],
|
||||
defaultTitle = "Untitled chat",
|
||||
) => {
|
||||
const firstUserMessage = messages.find(
|
||||
(message): message is Extract<ChatMessage, { role: "user" }> =>
|
||||
message.role === "user",
|
||||
);
|
||||
const title = firstUserMessage?.content.trim() || defaultTitle;
|
||||
return title.slice(0, 80);
|
||||
};
|
||||
|
||||
export const getConversationPreviewMessage = (
|
||||
messages: ChatMessage[],
|
||||
): AssistantMessage | null => {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
message.status.kind !== "error" &&
|
||||
message.skeletons?.length
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getElementsCenter = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
): { x: number; y: number } | null => {
|
||||
if (!elements.length) {
|
||||
return null;
|
||||
}
|
||||
const bounds = getCommonBounds(elements);
|
||||
if (bounds.some((value) => Number.isNaN(value) || !Number.isFinite(value))) {
|
||||
return null;
|
||||
}
|
||||
const [minX, minY, maxX, maxY] = bounds;
|
||||
return {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
};
|
||||
};
|
||||
@@ -1,435 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TTAStreamFetch } from "./client";
|
||||
import { AI_CLIENT_ERRORS } from "./utils";
|
||||
|
||||
function createMockStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(chunks[index]));
|
||||
index++;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const createChunk = (data: unknown) => `data: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
const createTransportFetch =
|
||||
() =>
|
||||
async ({
|
||||
method,
|
||||
headers,
|
||||
payload,
|
||||
signal,
|
||||
}: {
|
||||
method: "POST";
|
||||
headers: Record<string, string>;
|
||||
payload: unknown;
|
||||
signal?: AbortSignal;
|
||||
}) =>
|
||||
global.fetch("https://api.example.com/tta/stream", {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
describe("TTAStreamFetch", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("streams partial payloads and returns the final payload", async () => {
|
||||
const onStarted = vi.fn();
|
||||
const onMessage = vi.fn();
|
||||
const onChunk = vi.fn();
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body: createMockStream([
|
||||
createChunk({
|
||||
type: "started",
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
lifecycleStatus: "pending",
|
||||
}),
|
||||
createChunk({
|
||||
type: "message",
|
||||
message: "Something went wrong. Retrying...",
|
||||
}),
|
||||
createChunk({
|
||||
type: "partial",
|
||||
skeletons: [],
|
||||
}),
|
||||
createChunk({
|
||||
type: "done",
|
||||
lifecycleStatus: "completed",
|
||||
finishReason: "stop",
|
||||
skeletons: [],
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
updatedAt: 1710000000000,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
onStarted,
|
||||
onMessage,
|
||||
onChunk,
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(onStarted).toHaveBeenCalledWith({
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
lifecycleStatus: "pending",
|
||||
});
|
||||
expect(onMessage).toHaveBeenCalledWith({
|
||||
message: "Something went wrong. Retrying...",
|
||||
});
|
||||
expect(onChunk).toHaveBeenCalledWith({
|
||||
skeletons: [],
|
||||
isComplete: false,
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.finalPayload).toEqual({
|
||||
skeletons: [],
|
||||
isComplete: true,
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
finishReason: "stop",
|
||||
lifecycleStatus: "completed",
|
||||
updatedAt: 1710000000000,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the server's isComplete flag on partial chunks", async () => {
|
||||
const onChunk = vi.fn();
|
||||
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body: createMockStream([
|
||||
createChunk({ type: "partial", skeletons: [skeleton] }),
|
||||
createChunk({
|
||||
type: "partial",
|
||||
skeletons: [skeleton],
|
||||
isComplete: true,
|
||||
}),
|
||||
createChunk({
|
||||
type: "done",
|
||||
lifecycleStatus: "completed",
|
||||
finishReason: "stop",
|
||||
skeletons: [skeleton],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
onChunk,
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(onChunk).toHaveBeenNthCalledWith(1, {
|
||||
skeletons: [skeleton],
|
||||
isComplete: false,
|
||||
});
|
||||
expect(onChunk).toHaveBeenNthCalledWith(2, {
|
||||
skeletons: [skeleton],
|
||||
isComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the started chat id before a later stream error", async () => {
|
||||
const onStarted = vi.fn();
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body: createMockStream([
|
||||
createChunk({
|
||||
type: "started",
|
||||
chatId: "chat-error",
|
||||
turnId: "turn-error",
|
||||
messageId: "message-error",
|
||||
lifecycleStatus: "pending",
|
||||
}),
|
||||
createChunk({
|
||||
type: "error",
|
||||
lifecycleStatus: "failed",
|
||||
error: {
|
||||
code: 422,
|
||||
message: "Generation failed",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
onStarted,
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(onStarted).toHaveBeenCalledWith({
|
||||
chatId: "chat-error",
|
||||
turnId: "turn-error",
|
||||
messageId: "message-error",
|
||||
lifecycleStatus: "pending",
|
||||
});
|
||||
expect(result.error).toEqual({
|
||||
code: 422,
|
||||
lifecycleStatus: "failed",
|
||||
message: "Generation failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns rate limit metadata on 429 responses", async () => {
|
||||
const headers = new Headers();
|
||||
headers.set("X-Ratelimit-Limit", "100");
|
||||
headers.set("X-Ratelimit-Remaining", "0");
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers,
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
json: async () => ({ error: "Rate limit exceeded" }),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(result.error).toEqual({
|
||||
code: 429,
|
||||
message: "Rate limit exceeded",
|
||||
});
|
||||
expect(result.rateLimit).toBe(100);
|
||||
expect(result.rateLimitRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("returns rate limit metadata on successful responses", async () => {
|
||||
const headers = new Headers();
|
||||
headers.set("X-Ratelimit-Limit", "100");
|
||||
headers.set("X-Ratelimit-Remaining", "0");
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers,
|
||||
body: createMockStream([
|
||||
createChunk({
|
||||
type: "done",
|
||||
lifecycleStatus: "completed",
|
||||
finishReason: "stop",
|
||||
skeletons: [],
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.rateLimit).toBe(100);
|
||||
expect(result.rateLimitRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("reports an interrupted stream when EOF arrives without a terminal chunk", async () => {
|
||||
const onChunk = vi.fn();
|
||||
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body: createMockStream([
|
||||
createChunk({
|
||||
type: "started",
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
lifecycleStatus: "pending",
|
||||
}),
|
||||
createChunk({ type: "partial", skeletons: [skeleton] }),
|
||||
// stream cut here: no `done`/`error` chunk, no `[DONE]` sentinel
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
onChunk,
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(onChunk).toHaveBeenCalledWith({
|
||||
skeletons: [skeleton],
|
||||
isComplete: false,
|
||||
});
|
||||
expect(result.finalPayload).toBeFalsy();
|
||||
expect(result.error).toMatchObject({
|
||||
code: AI_CLIENT_ERRORS.STREAM_INTERRUPTED,
|
||||
lifecycleStatus: "failed",
|
||||
});
|
||||
expect(result.error?.message).toMatch(/connection interrupted/i);
|
||||
});
|
||||
|
||||
it("reports a server-ended stream when [DONE] arrives without a result", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body: createMockStream([
|
||||
createChunk({
|
||||
type: "started",
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
lifecycleStatus: "pending",
|
||||
}),
|
||||
"data: [DONE]\n\n",
|
||||
]),
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(result.finalPayload).toBeFalsy();
|
||||
expect(result.error).toMatchObject({
|
||||
code: AI_CLIENT_ERRORS.STREAM_INTERRUPTED,
|
||||
lifecycleStatus: "failed",
|
||||
});
|
||||
expect(result.error?.message).toMatch(/ended the stream/i);
|
||||
});
|
||||
|
||||
it("drains the stream to EOF after the done chunk so the fetch completes cleanly", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [
|
||||
createChunk({
|
||||
type: "done",
|
||||
lifecycleStatus: "completed",
|
||||
finishReason: "stop",
|
||||
skeletons: [],
|
||||
}),
|
||||
"data: [DONE]\n\n",
|
||||
];
|
||||
let index = 0;
|
||||
let fullyRead = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (index >= chunks.length) {
|
||||
fullyRead = true;
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(chunks[index]));
|
||||
index++;
|
||||
},
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body,
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
// the result resolves at the `done` frame, before the trailing sentinel…
|
||||
expect(result.error).toBeNull();
|
||||
// …and the detached drain then reads the body to EOF (no ERR_ABORTED)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(fullyRead).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an abort error when the request is cancelled", async () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
global.fetch = vi.fn().mockImplementation(() => {
|
||||
abortController.abort();
|
||||
const error = new Error("The operation was aborted");
|
||||
error.name = "AbortError";
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const result = await TTAStreamFetch({
|
||||
payload: { prompt: "hello" },
|
||||
signal: abortController.signal,
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(result.error).toEqual({
|
||||
code: 499,
|
||||
lifecycleStatus: "aborted",
|
||||
message: "Request aborted",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes chat ids through without trimming", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
body: createMockStream([
|
||||
createChunk({
|
||||
type: "done",
|
||||
finishReason: "stop",
|
||||
skeletons: [],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
await TTAStreamFetch({
|
||||
payload: {
|
||||
prompt: "hello",
|
||||
chatId: " chat-with-spaces ",
|
||||
},
|
||||
fetch: createTransportFetch(),
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.example.com/tta/stream",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
prompt: "hello",
|
||||
chatId: " chat-with-spaces ",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
import { extractRateLimitHeaders, iterateSSEJSONChunks } from "../data/sse";
|
||||
|
||||
import { AI_CLIENT_ERRORS } from "./utils";
|
||||
|
||||
import type {
|
||||
AIAssistantLifecycleStatus,
|
||||
AIGenerateRequestPayload,
|
||||
StreamChunk,
|
||||
AIStreamFinalPayload,
|
||||
AIStreamMessagePayload,
|
||||
AIStreamPartialPayload,
|
||||
AIStreamStartedPayload,
|
||||
AI_ERROR_CODE,
|
||||
} from "./types";
|
||||
|
||||
type AIClientError = Error & {
|
||||
code?: AI_ERROR_CODE | number;
|
||||
};
|
||||
|
||||
const getErrorCode = (error: unknown): AI_ERROR_CODE | number | undefined => {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const maybeCode = (error as { code?: unknown }).code;
|
||||
return typeof maybeCode === "number" ? maybeCode : undefined;
|
||||
};
|
||||
|
||||
const withErrorCode = (
|
||||
error: Error,
|
||||
code?: AI_ERROR_CODE | number,
|
||||
): AIClientError => {
|
||||
const typedError = error as AIClientError;
|
||||
if (typeof code === "number") {
|
||||
typedError.code = code;
|
||||
}
|
||||
return typedError;
|
||||
};
|
||||
|
||||
const parseErrorResponseMessage = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<string> => {
|
||||
const jsonPayload = await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null as { error?: string } | null);
|
||||
if (jsonPayload?.error) {
|
||||
return jsonPayload.error;
|
||||
}
|
||||
|
||||
const textPayload = await response.text().catch(() => "");
|
||||
return textPayload || fallback;
|
||||
};
|
||||
|
||||
const createHttpStatusError = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<AIClientError> => {
|
||||
const message = await parseErrorResponseMessage(response, fallback);
|
||||
return withErrorCode(new Error(message), response.status);
|
||||
};
|
||||
|
||||
export type AIChatTruncateRequest = {
|
||||
chatId: string;
|
||||
keepThroughTurnId?: string | null;
|
||||
};
|
||||
|
||||
export type AIChatTruncateResponse = {
|
||||
ok: boolean;
|
||||
chatId: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
export interface TTAStreamFetchRequest {
|
||||
method: "POST";
|
||||
headers: {
|
||||
"Content-Type": "application/json";
|
||||
Accept: "text/event-stream";
|
||||
};
|
||||
payload: AIGenerateRequestPayload;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type TTAStreamResponseFetch = (
|
||||
request: TTAStreamFetchRequest,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface TTATruncateFetchRequest {
|
||||
method: "POST";
|
||||
headers: {
|
||||
"Content-Type": "application/json";
|
||||
};
|
||||
payload: AIChatTruncateRequest;
|
||||
}
|
||||
|
||||
export type TTATruncateResponseFetch = (
|
||||
request: TTATruncateFetchRequest,
|
||||
) => Promise<Response>;
|
||||
|
||||
export const truncateChat = async (
|
||||
payload: AIChatTruncateRequest,
|
||||
options: {
|
||||
fetch: TTATruncateResponseFetch;
|
||||
},
|
||||
): Promise<AIChatTruncateResponse> => {
|
||||
const response = await options.fetch({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await createHttpStatusError(
|
||||
response,
|
||||
`AI server request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (!data || typeof data.chatId !== "string") {
|
||||
throw withErrorCode(
|
||||
new Error("AI server returned an invalid truncate response"),
|
||||
500,
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: Boolean(data.ok),
|
||||
chatId: data.chatId,
|
||||
...(typeof data.updatedAt === "number"
|
||||
? { updatedAt: data.updatedAt }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export interface TTAStreamFetchOptions {
|
||||
payload: AIGenerateRequestPayload;
|
||||
onStarted?: (payload: AIStreamStartedPayload) => void;
|
||||
onMessage?: (payload: AIStreamMessagePayload) => void;
|
||||
onChunk?: (payload: AIStreamPartialPayload) => void;
|
||||
signal?: AbortSignal;
|
||||
onStreamCreated?: () => void;
|
||||
fetch: TTAStreamResponseFetch;
|
||||
}
|
||||
|
||||
export type TTAStreamError = {
|
||||
code?: AI_ERROR_CODE | number;
|
||||
message: string;
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "failed" | "aborted">;
|
||||
};
|
||||
|
||||
export type TTAStreamFetchResult = {
|
||||
rateLimit?: number | null;
|
||||
rateLimitRemaining?: number | null;
|
||||
} & (
|
||||
| {
|
||||
finalPayload: AIStreamFinalPayload;
|
||||
error: null;
|
||||
}
|
||||
| {
|
||||
error: TTAStreamError;
|
||||
finalPayload?: null;
|
||||
}
|
||||
);
|
||||
|
||||
export interface TTATransportAdapter {
|
||||
stream(
|
||||
options: Omit<TTAStreamFetchOptions, "fetch">,
|
||||
): Promise<TTAStreamFetchResult>;
|
||||
truncate(payload: AIChatTruncateRequest): Promise<AIChatTruncateResponse>;
|
||||
}
|
||||
|
||||
export interface TTADefaultTransportAdapterConfig {
|
||||
stream: TTAStreamResponseFetch;
|
||||
truncate: TTATruncateResponseFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a terminal `done`/`error` chunk the response body still holds a few
|
||||
* unread frames (the `[DONE]` sentinel, then EOF). Read them so the stream
|
||||
* completes instead of being abandoned mid-body — an abandoned body forces
|
||||
* the browser to tear down the connection, while a drained one can return to
|
||||
* the keep-alive pool. Detached and bounded: the result is resolved before
|
||||
* this runs, and a server that doesn't close promptly gets cancelled after
|
||||
* the deadline (the old behavior).
|
||||
*
|
||||
* NOTE this does NOT silence the `net::ERR_ABORTED` annotation DevTools
|
||||
* attaches to these requests — verified empirically that Chrome logs it for
|
||||
* cross-origin SSE fetches even when the body is read fully to EOF.
|
||||
*/
|
||||
const drainStreamToEof = (body: ReadableStream<Uint8Array>) => {
|
||||
void (async () => {
|
||||
try {
|
||||
// the for-await teardown releases the SSE parser's reader lock
|
||||
// asynchronously — wait briefly for it
|
||||
for (let i = 0; i < 10 && body.locked; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
if (body.locked) {
|
||||
return;
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const deadline = setTimeout(() => {
|
||||
reader.cancel().catch(() => {});
|
||||
}, 2_000);
|
||||
while (!(await reader.read()).done) {
|
||||
// discard trailing frames
|
||||
}
|
||||
clearTimeout(deadline);
|
||||
} catch {
|
||||
// teardown races are fine — nothing user-visible depends on the drain
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const toStreamError = (
|
||||
message: string,
|
||||
code?: AI_ERROR_CODE | number,
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "failed" | "aborted">,
|
||||
): TTAStreamError => ({
|
||||
message,
|
||||
code,
|
||||
...(lifecycleStatus ? { lifecycleStatus } : {}),
|
||||
});
|
||||
|
||||
export const TTAStreamFetch = async (
|
||||
options: TTAStreamFetchOptions,
|
||||
): Promise<TTAStreamFetchResult> => {
|
||||
const {
|
||||
payload,
|
||||
onStarted,
|
||||
onMessage,
|
||||
onChunk,
|
||||
onStreamCreated,
|
||||
signal,
|
||||
fetch: streamFetch,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const response = await streamFetch({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
payload,
|
||||
signal,
|
||||
});
|
||||
|
||||
const rateLimitInfo = extractRateLimitHeaders(response.headers);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: toStreamError(
|
||||
await parseErrorResponseMessage(
|
||||
response,
|
||||
`AI server streaming request failed with status ${response.status}`,
|
||||
),
|
||||
response.status,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const stream = response.body;
|
||||
|
||||
if (!stream) {
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: toStreamError("Couldn't get reader from response body", 500),
|
||||
};
|
||||
}
|
||||
|
||||
onStreamCreated?.();
|
||||
let startedPayload: AIStreamStartedPayload | null = null;
|
||||
let receivedDoneSentinel = false;
|
||||
|
||||
for await (const event of iterateSSEJSONChunks<StreamChunk>(stream, {
|
||||
signal,
|
||||
onInvalidJSON: (rawPayload) => {
|
||||
console.warn("AI Client: Failed to parse JSON payload", rawPayload);
|
||||
},
|
||||
onDoneSentinel: () => {
|
||||
receivedDoneSentinel = true;
|
||||
},
|
||||
})) {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
startedPayload = {
|
||||
chatId: event.chatId,
|
||||
turnId: event.turnId,
|
||||
messageId: event.messageId,
|
||||
...(event.lifecycleStatus
|
||||
? { lifecycleStatus: event.lifecycleStatus }
|
||||
: {}),
|
||||
...(typeof event.updatedAt === "number"
|
||||
? { updatedAt: event.updatedAt }
|
||||
: {}),
|
||||
};
|
||||
onStarted?.(startedPayload);
|
||||
break;
|
||||
case "message":
|
||||
onMessage?.({ message: event.message });
|
||||
break;
|
||||
case "partial":
|
||||
onChunk?.({
|
||||
skeletons: event.skeletons ?? [],
|
||||
isComplete: event.isComplete ?? false,
|
||||
});
|
||||
break;
|
||||
case "done":
|
||||
drainStreamToEof(stream);
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
finalPayload: {
|
||||
skeletons: event.skeletons ?? [],
|
||||
isComplete: true,
|
||||
chatId: event.chatId ?? startedPayload?.chatId,
|
||||
turnId: event.turnId ?? startedPayload?.turnId,
|
||||
messageId: event.messageId ?? startedPayload?.messageId,
|
||||
finishReason: event.finishReason,
|
||||
...(event.lifecycleStatus
|
||||
? { lifecycleStatus: event.lifecycleStatus }
|
||||
: {}),
|
||||
...(typeof event.updatedAt === "number"
|
||||
? { updatedAt: event.updatedAt }
|
||||
: {}),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
case "error":
|
||||
drainStreamToEof(stream);
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: toStreamError(
|
||||
event.error.message ?? "AI streaming error",
|
||||
event.error.code,
|
||||
event.lifecycleStatus ?? "failed",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: toStreamError("Request aborted", 499, "aborted"),
|
||||
};
|
||||
}
|
||||
|
||||
// Every deliberately-ended generation terminates with a `done` or `error`
|
||||
// chunk (the success path additionally appends a `[DONE]` sentinel — see
|
||||
// StreamingResponse.end() server-side). Reaching EOF without one means
|
||||
// the stream was cut (proxy idle timeout, server restart, network blip).
|
||||
// Never fabricate an empty success here: it would overwrite the streamed
|
||||
// partial skeletons and wipe the canvas preview (C2 in tta.md).
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: toStreamError(
|
||||
receivedDoneSentinel
|
||||
? "The AI server ended the stream without a result"
|
||||
: "Connection interrupted before the response completed",
|
||||
AI_CLIENT_ERRORS.STREAM_INTERRUPTED,
|
||||
"failed",
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === "AbortError" || signal?.aborted) {
|
||||
return {
|
||||
error: toStreamError("Request aborted", 499, "aborted"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: toStreamError(
|
||||
error instanceof Error ? error.message : "AI streaming failed",
|
||||
getErrorCode(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export class TTADefaultTransportAdapter implements TTATransportAdapter {
|
||||
constructor(private readonly config: TTADefaultTransportAdapterConfig) {}
|
||||
|
||||
stream: TTATransportAdapter["stream"] = async (options) => {
|
||||
return TTAStreamFetch({
|
||||
...options,
|
||||
fetch: this.config.stream,
|
||||
});
|
||||
};
|
||||
|
||||
truncate: TTATransportAdapter["truncate"] = async (payload) => {
|
||||
return truncateChat(payload, {
|
||||
fetch: this.config.truncate,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createStore, del, entries, set } from "idb-keyval";
|
||||
|
||||
import { compareConversationsByUpdatedAt } from "./chatHelpers";
|
||||
|
||||
import type { ChatConversation, TTAPersistenceAdapter } from "./types";
|
||||
|
||||
const ALL_CHATS_DB = "excalidraw-tta-all-chats";
|
||||
const ALL_CHATS_STORE = "excalidraw-tta-all-chats-store";
|
||||
|
||||
const chatsStore = createStore(ALL_CHATS_DB, ALL_CHATS_STORE);
|
||||
|
||||
const isChatConversation = (value: unknown): value is ChatConversation =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as ChatConversation).id === "string" &&
|
||||
Array.isArray((value as ChatConversation).messages);
|
||||
|
||||
/**
|
||||
* Per-chat persistence: one IndexedDB entry per chat, keyed by the chat id, so
|
||||
* saving the active chat never rewrites (or clobbers) the others.
|
||||
*/
|
||||
export const TTAIndexedDBAdapter: TTAPersistenceAdapter = {
|
||||
async loadChats() {
|
||||
try {
|
||||
const storedChats = await entries<string, unknown>(chatsStore);
|
||||
return storedChats
|
||||
.map(([, chat]) => chat)
|
||||
.filter(isChatConversation)
|
||||
.sort(compareConversationsByUpdatedAt);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load TTA chats from IndexedDB:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
async saveChat(chat) {
|
||||
try {
|
||||
await set(chat.id, chat, chatsStore);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save TTA chat to IndexedDB:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteChat(id) {
|
||||
try {
|
||||
await del(id, chatsStore);
|
||||
} catch (error) {
|
||||
console.warn("Failed to delete TTA chat from IndexedDB:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,818 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getBoundTextMaxWidth,
|
||||
getCommonBounds,
|
||||
isTextElement,
|
||||
measureText,
|
||||
type ExcalidrawElementSkeleton,
|
||||
} from "@excalidraw/element";
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
insertAISkeletons,
|
||||
INTERMEDIATE_PREVIEW_ELEMENT_KEY,
|
||||
} from "./insertAISkeletons";
|
||||
|
||||
import type { AppClassProperties, NormalizedZoomValue } from "../types";
|
||||
|
||||
const createTestApp = (
|
||||
initialElements: ExcalidrawElement[] = [],
|
||||
appStateOverrides: Partial<AppClassProperties["state"]> = {},
|
||||
) => {
|
||||
let elements = [...initialElements];
|
||||
let lastSync: {
|
||||
elements: ExcalidrawElement[];
|
||||
appState?: AppClassProperties["state"];
|
||||
} | null = null;
|
||||
|
||||
const app = {
|
||||
state: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
zoom: { value: 1 },
|
||||
selectedElementIds: {},
|
||||
...appStateOverrides,
|
||||
} as AppClassProperties["state"],
|
||||
scene: {
|
||||
getNonDeletedElements: () =>
|
||||
elements.filter(
|
||||
(element): element is NonDeletedExcalidrawElement =>
|
||||
!element.isDeleted,
|
||||
),
|
||||
getElementsIncludingDeleted: () => elements,
|
||||
} as unknown as AppClassProperties["scene"],
|
||||
syncActionResult: ({
|
||||
elements: nextElements,
|
||||
appState,
|
||||
}: {
|
||||
elements: ExcalidrawElement[];
|
||||
appState?: AppClassProperties["state"];
|
||||
}) => {
|
||||
elements = nextElements;
|
||||
lastSync = { elements: nextElements, appState };
|
||||
},
|
||||
} as unknown as AppClassProperties;
|
||||
|
||||
return {
|
||||
app,
|
||||
getElements: () => elements,
|
||||
getLastSync: () => lastSync,
|
||||
};
|
||||
};
|
||||
|
||||
const getById = <T extends { id: string }>(
|
||||
elements: readonly T[],
|
||||
id: string,
|
||||
) => elements.find((element) => element.id === id);
|
||||
|
||||
const getInsertedBoundsCenter = (
|
||||
inserted: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(inserted);
|
||||
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
|
||||
};
|
||||
|
||||
describe("insertAISkeletons", () => {
|
||||
it("preserves existing deleted elements when inserting", () => {
|
||||
const setup = createTestApp();
|
||||
insertAISkeletons(
|
||||
setup.app,
|
||||
[
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "deleted-rect",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
{ targetCenter: { x: 0, y: 0 } },
|
||||
);
|
||||
const deletedElement = {
|
||||
...setup.getElements()[0],
|
||||
isDeleted: true,
|
||||
} as ExcalidrawElement;
|
||||
|
||||
const { app, getLastSync } = createTestApp([deletedElement]);
|
||||
|
||||
insertAISkeletons(
|
||||
app,
|
||||
[
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "new-ellipse",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 30,
|
||||
},
|
||||
],
|
||||
{ targetCenter: { x: 0, y: 0 } },
|
||||
);
|
||||
|
||||
expect(getLastSync()!.elements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "deleted-rect", isDeleted: true }),
|
||||
expect.objectContaining({ id: "new-ellipse", isDeleted: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks replaced generation elements deleted when inserting", () => {
|
||||
const setup = createTestApp();
|
||||
insertAISkeletons(
|
||||
setup.app,
|
||||
[
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "old-rect",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
{ generationId: "old-generation", targetCenter: { x: 0, y: 0 } },
|
||||
);
|
||||
const oldElement = setup.getElements()[0];
|
||||
|
||||
const { app, getLastSync } = createTestApp([oldElement]);
|
||||
|
||||
insertAISkeletons(
|
||||
app,
|
||||
[
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "new-ellipse",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 30,
|
||||
},
|
||||
],
|
||||
{
|
||||
generationId: "new-generation",
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
deleteGenerationTags: ["old-generation"],
|
||||
},
|
||||
);
|
||||
|
||||
const synced = getLastSync()!.elements;
|
||||
const deletedOldElement = getById(synced, "old-rect")!;
|
||||
const newElement = getById(synced, "new-ellipse")!;
|
||||
|
||||
expect(deletedOldElement).toMatchObject({
|
||||
id: "old-rect",
|
||||
isDeleted: true,
|
||||
});
|
||||
expect(deletedOldElement.version).toBeGreaterThan(oldElement.version);
|
||||
expect(newElement).toMatchObject({
|
||||
id: "new-ellipse",
|
||||
isDeleted: false,
|
||||
customData: expect.objectContaining({
|
||||
aiSidebarGenerationId: "new-generation",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("marks intermediate preview elements", () => {
|
||||
const { app, getLastSync } = createTestApp();
|
||||
|
||||
insertAISkeletons(
|
||||
app,
|
||||
[
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "preview-rect",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
{
|
||||
generationId: "preview-generation",
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
intermediatePreviewElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(getLastSync()!.elements[0]).toMatchObject({
|
||||
id: "preview-rect",
|
||||
customData: expect.objectContaining({
|
||||
aiSidebarGenerationId: "preview-generation",
|
||||
[INTERMEDIATE_PREVIEW_ELEMENT_KEY]: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces existing elements with matching inserted ids", () => {
|
||||
const setup = createTestApp();
|
||||
insertAISkeletons(
|
||||
setup.app,
|
||||
[
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "stable-id",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
{ targetCenter: { x: 0, y: 0 } },
|
||||
);
|
||||
const deletedStableElement = {
|
||||
...setup.getElements()[0],
|
||||
isDeleted: true,
|
||||
} as ExcalidrawElement;
|
||||
|
||||
const { app, getLastSync } = createTestApp([deletedStableElement]);
|
||||
|
||||
insertAISkeletons(
|
||||
app,
|
||||
[
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "stable-id",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 30,
|
||||
},
|
||||
],
|
||||
{ targetCenter: { x: 0, y: 0 } },
|
||||
);
|
||||
|
||||
const synced = getLastSync()!.elements;
|
||||
const matchingElements = synced.filter(
|
||||
(element) => element.id === "stable-id",
|
||||
);
|
||||
|
||||
expect(matchingElements).toHaveLength(1);
|
||||
expect(matchingElements[0]).toMatchObject({
|
||||
id: "stable-id",
|
||||
type: "ellipse",
|
||||
isDeleted: false,
|
||||
});
|
||||
expect(matchingElements[0].version).toBeGreaterThan(
|
||||
deletedStableElement.version,
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts normal elements and preserves relative offsets", () => {
|
||||
const { app } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "rect-1",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "ell-1",
|
||||
x: 200,
|
||||
y: 80,
|
||||
width: 60,
|
||||
height: 40,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-1",
|
||||
x: 50,
|
||||
y: 30,
|
||||
text: "Hello world",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const inserted = insertAISkeletons(app, skeletons, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
selectInsertedElements: true,
|
||||
});
|
||||
|
||||
const rect = getById(inserted, "rect-1")!;
|
||||
const ellipse = getById(inserted, "ell-1")!;
|
||||
const text = getById(inserted, "text-1")!;
|
||||
|
||||
const rectSkeleton = skeletons[0];
|
||||
const ellipseSkeleton = skeletons[1];
|
||||
const textSkeleton = skeletons[2];
|
||||
|
||||
expect(ellipse.x - rect.x).toBeCloseTo(
|
||||
ellipseSkeleton.x! - rectSkeleton.x!,
|
||||
);
|
||||
expect(ellipse.y - rect.y).toBeCloseTo(
|
||||
ellipseSkeleton.y! - rectSkeleton.y!,
|
||||
);
|
||||
expect(text.x - rect.x).toBeCloseTo(textSkeleton.x! - rectSkeleton.x!);
|
||||
expect(text.y - rect.y).toBeCloseTo(textSkeleton.y! - rectSkeleton.y!);
|
||||
});
|
||||
|
||||
it("repairs bound text elements on insert", () => {
|
||||
const { app, getLastSync } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "rect-1",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 80,
|
||||
boundElements: [{ type: "text", id: "text-1" }],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-1",
|
||||
x: 120,
|
||||
y: 120,
|
||||
text: "Bound label",
|
||||
fontSize: 18,
|
||||
fontFamily: 1,
|
||||
containerId: "missing-container",
|
||||
},
|
||||
];
|
||||
|
||||
insertAISkeletons(app, skeletons, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const synced = getLastSync()!.elements;
|
||||
const rect = getById(synced, "rect-1")!;
|
||||
const text = getById(synced, "text-1")!;
|
||||
|
||||
if (!isTextElement(text)) {
|
||||
throw new Error("Expected text element");
|
||||
}
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rect.id);
|
||||
expect(rect.boundElements?.some((el) => el.id === text.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles arrows with bindings and labels", () => {
|
||||
const { app } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "rect-a",
|
||||
x: 20,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 60,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "rect-b",
|
||||
x: 260,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 60,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: "arrow-1",
|
||||
x: 140,
|
||||
y: 70,
|
||||
width: 120,
|
||||
height: 0,
|
||||
start: {
|
||||
id: "rect-a",
|
||||
},
|
||||
end: {
|
||||
id: "rect-b",
|
||||
},
|
||||
label: {
|
||||
text: "Flow",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const inserted = insertAISkeletons(app, skeletons, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const rectA = getById(inserted, "rect-a")!;
|
||||
const rectB = getById(inserted, "rect-b")!;
|
||||
const arrow = getById(inserted, "arrow-1") as Extract<
|
||||
NonDeletedExcalidrawElement,
|
||||
{ type: "arrow" }
|
||||
>;
|
||||
const label = inserted.find(
|
||||
(element) => element.type === "text" && element.containerId === arrow.id,
|
||||
);
|
||||
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow.type).toBe("arrow");
|
||||
expect(arrow.startBinding?.elementId).toBe(rectA.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectB.id);
|
||||
expect(label).toBeDefined();
|
||||
if (label) {
|
||||
expect(
|
||||
arrow.boundElements?.some(
|
||||
(boundElement) => boundElement.id === label.id,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps frame membership for large children when regenerating ids", () => {
|
||||
const { app, getElements } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "child-1",
|
||||
x: 1000,
|
||||
y: 1200,
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
boundElements: [{ type: "text", id: "child-text" }],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "child-text",
|
||||
x: 1100,
|
||||
y: 1250,
|
||||
text: "Big child",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
containerId: "child-1",
|
||||
},
|
||||
{
|
||||
type: "frame",
|
||||
id: "frame-1",
|
||||
name: "Frame with big children",
|
||||
width: 800,
|
||||
height: 450,
|
||||
children: ["child-1"],
|
||||
},
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
insertAISkeletons(app, skeletons, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
regenerateIds: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
const elements = getElements();
|
||||
const frame = elements.find((element) => element.type === "frame")!;
|
||||
const frameChildren = elements.filter(
|
||||
(element) => element.frameId === frame.id,
|
||||
);
|
||||
const frameChild = frameChildren.find((element) => element.type !== "text");
|
||||
const boundText = frameChildren.find((element) => element.type === "text");
|
||||
|
||||
expect(frameChild).toBeDefined();
|
||||
expect(boundText).toBeDefined();
|
||||
expect(frame.width).toBe(800);
|
||||
expect(frame.height).toBe(450);
|
||||
});
|
||||
|
||||
it("preserves layout spacing across multiple frames", () => {
|
||||
const { app } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "frame",
|
||||
id: "frame-left",
|
||||
name: "Left",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
children: ["left-child"],
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "left-child",
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
{
|
||||
type: "frame",
|
||||
id: "frame-right",
|
||||
name: "Right",
|
||||
x: 500,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
children: ["right-child"],
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "right-child",
|
||||
x: 520,
|
||||
y: 30,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
];
|
||||
|
||||
const inserted = insertAISkeletons(app, skeletons, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const leftFrame = inserted.find(
|
||||
(element) => element.type === "frame" && element.name === "Left",
|
||||
) as Extract<NonDeletedExcalidrawElement, { type: "frame" }>;
|
||||
const rightFrame = inserted.find(
|
||||
(element) => element.type === "frame" && element.name === "Right",
|
||||
) as Extract<NonDeletedExcalidrawElement, { type: "frame" }>;
|
||||
|
||||
expect(leftFrame).toBeDefined();
|
||||
expect(rightFrame).toBeDefined();
|
||||
|
||||
const left = leftFrame!;
|
||||
const right = rightFrame!;
|
||||
|
||||
const originalDistance =
|
||||
skeletons[2].x! +
|
||||
skeletons[2].width! / 2 -
|
||||
(skeletons[0].x! + skeletons[0].width! / 2);
|
||||
const insertedDistance =
|
||||
right.x + right.width / 2 - (left.x + left.width / 2);
|
||||
|
||||
expect(insertedDistance).toBeCloseTo(originalDistance);
|
||||
});
|
||||
|
||||
it("handles arrows that bind to frames", () => {
|
||||
const { app } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "frame",
|
||||
id: "frame-1",
|
||||
name: "Target frame",
|
||||
x: 300,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
children: ["frame-child"],
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "frame-child",
|
||||
x: 320,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "source",
|
||||
x: 0,
|
||||
y: 40,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: "arrow-to-frame",
|
||||
x: 80,
|
||||
y: 70,
|
||||
width: 220,
|
||||
height: 0,
|
||||
elbowed: true,
|
||||
start: { id: "source" },
|
||||
end: { id: "frame-1" },
|
||||
} as ExcalidrawElementSkeleton,
|
||||
];
|
||||
|
||||
const inserted = insertAISkeletons(app, skeletons, {
|
||||
regenerateIds: true,
|
||||
selectInsertedElements: true,
|
||||
});
|
||||
|
||||
const insertedFrame = inserted.find(
|
||||
(element) => element.type === "frame",
|
||||
) as Extract<NonDeletedExcalidrawElement, { type: "frame" }>;
|
||||
const insertedArrow = inserted.find(
|
||||
(element) => element.type === "arrow",
|
||||
) as Extract<NonDeletedExcalidrawElement, { type: "arrow" }>;
|
||||
|
||||
expect(insertedFrame).toBeDefined();
|
||||
expect(insertedArrow).toBeDefined();
|
||||
expect(insertedArrow.endBinding?.elementId).toBe(insertedFrame.id);
|
||||
});
|
||||
|
||||
it("verifies near-threshold bound text wraps and restores single-line text", () => {
|
||||
const { app, getLastSync } = createTestApp();
|
||||
const originalText = "Hello world";
|
||||
const fontFamily = 1;
|
||||
const fontSize = 16;
|
||||
const lineHeight = 1.25 as ExcalidrawTextElement["lineHeight"];
|
||||
const singleLineWidth = measureText(
|
||||
originalText,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
).width;
|
||||
|
||||
let ellipseSize: number | null = null;
|
||||
for (let candidate = 100; candidate <= 220; candidate += 1) {
|
||||
const maxWidth = getBoundTextMaxWidth(
|
||||
{
|
||||
type: "ellipse",
|
||||
width: candidate,
|
||||
height: candidate,
|
||||
} as any,
|
||||
{
|
||||
fontSize,
|
||||
} as any,
|
||||
);
|
||||
const overflow = singleLineWidth - maxWidth;
|
||||
if (overflow > 0.05 && overflow <= 1.4) {
|
||||
ellipseSize = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(ellipseSize).not.toBeNull();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "bubble",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: ellipseSize!,
|
||||
height: ellipseSize!,
|
||||
boundElements: [{ type: "text", id: "label" }],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "label",
|
||||
x: 120,
|
||||
y: 120,
|
||||
text: "Hello\nworld",
|
||||
originalText,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
lineHeight,
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
autoResize: true,
|
||||
containerId: "bubble",
|
||||
},
|
||||
];
|
||||
|
||||
insertAISkeletons(app, skeletons, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const synced = getLastSync()!.elements;
|
||||
const bubble = getById(synced, "bubble")!;
|
||||
const label = getById(synced, "label");
|
||||
|
||||
expect(label).toBeDefined();
|
||||
if (!label || !isTextElement(label)) {
|
||||
throw new Error("Expected text element");
|
||||
}
|
||||
|
||||
expect(label.text).toBe(originalText);
|
||||
expect(bubble.width).toBeGreaterThanOrEqual(ellipseSize!);
|
||||
});
|
||||
|
||||
it("does not force cross-frame arrows into a child frame", () => {
|
||||
const { app } = createTestApp();
|
||||
|
||||
const skeletons: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "frame",
|
||||
id: "frame-a",
|
||||
name: "A",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
children: ["child-a"],
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
id: "child-a",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
{
|
||||
type: "frame",
|
||||
id: "frame-b",
|
||||
name: "B",
|
||||
x: 500,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
id: "cross-frame",
|
||||
x: 60,
|
||||
y: 50,
|
||||
width: 440,
|
||||
height: 0,
|
||||
elbowed: true,
|
||||
start: { id: "child-a" },
|
||||
end: { id: "frame-b" },
|
||||
} as ExcalidrawElementSkeleton,
|
||||
];
|
||||
|
||||
const inserted = insertAISkeletons(app, skeletons, {
|
||||
regenerateIds: true,
|
||||
selectInsertedElements: true,
|
||||
});
|
||||
|
||||
const arrow = inserted.find(
|
||||
(element) => element.type === "arrow",
|
||||
) as Extract<NonDeletedExcalidrawElement, { type: "arrow" }>;
|
||||
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow.startBinding?.elementId).toBeDefined();
|
||||
expect(arrow.endBinding?.elementId).toBeDefined();
|
||||
expect(arrow.frameId).toBeNull();
|
||||
});
|
||||
|
||||
it("centers inserted elements in the visible viewport when the canvas has a page offset", () => {
|
||||
const { app } = createTestApp([], {
|
||||
width: 1000,
|
||||
height: 800,
|
||||
offsetLeft: 480,
|
||||
offsetTop: 120,
|
||||
});
|
||||
|
||||
const inserted = insertAISkeletons(app, [
|
||||
{ type: "rectangle", id: "rect-1", x: 0, y: 0, width: 100, height: 50 },
|
||||
]);
|
||||
|
||||
// Scene-space viewport center is (width / 2) / zoom - scroll; the canvas
|
||||
// page offset cancels out of the client→scene transform and must NOT
|
||||
// shift the result (C4 in tta.md: the old formula landed this at
|
||||
// (20, 280) — i.e. offset px to the left/top of the visible center).
|
||||
const center = getInsertedBoundsCenter(inserted);
|
||||
expect(center.x).toBeCloseTo(500);
|
||||
expect(center.y).toBeCloseTo(400);
|
||||
});
|
||||
|
||||
it("centers inserted elements with offset, zoom and scroll combined", () => {
|
||||
const { app } = createTestApp([], {
|
||||
width: 1000,
|
||||
height: 800,
|
||||
offsetLeft: 480,
|
||||
offsetTop: 120,
|
||||
scrollX: 100,
|
||||
scrollY: -50,
|
||||
zoom: { value: 2 as NormalizedZoomValue },
|
||||
});
|
||||
|
||||
const inserted = insertAISkeletons(app, [
|
||||
{ type: "rectangle", id: "rect-1", x: 0, y: 0, width: 100, height: 50 },
|
||||
]);
|
||||
|
||||
// x: (1000 / 2) / 2 - 100 = 150 ; y: (800 / 2) / 2 - (-50) = 250
|
||||
// (old formula: (-90, 190))
|
||||
const center = getInsertedBoundsCenter(inserted);
|
||||
expect(center.x).toBeCloseTo(150);
|
||||
expect(center.y).toBeCloseTo(250);
|
||||
});
|
||||
|
||||
it("keeps offset-0 centering unchanged at zoom/scroll (excalidraw.com regression)", () => {
|
||||
const { app } = createTestApp([], {
|
||||
width: 1000,
|
||||
height: 800,
|
||||
scrollX: 100,
|
||||
scrollY: -50,
|
||||
zoom: { value: 2 as NormalizedZoomValue },
|
||||
});
|
||||
|
||||
const inserted = insertAISkeletons(app, [
|
||||
{ type: "rectangle", id: "rect-1", x: 0, y: 0, width: 100, height: 50 },
|
||||
]);
|
||||
|
||||
// Identical before and after the fix — pins the offset-0 behavior.
|
||||
const center = getInsertedBoundsCenter(inserted);
|
||||
expect(center.x).toBeCloseTo(150);
|
||||
expect(center.y).toBeCloseTo(250);
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
import {
|
||||
getCommonBounds,
|
||||
isBoundToContainer,
|
||||
getContainerElement,
|
||||
redrawTextBoundingBox,
|
||||
makeNextSelectedElementIds,
|
||||
Scene,
|
||||
CaptureUpdateAction,
|
||||
normalizeElbowArrows,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { convertToExcalidrawElements } from "@excalidraw/element";
|
||||
|
||||
import { viewportCoordsToSceneCoords } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { CaptureUpdateActionType } from "@excalidraw/element";
|
||||
|
||||
import { restoreElements } from "../data/restore";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
const getViewportCenter = (appState: AppClassProperties["state"]) => {
|
||||
// Viewport center in client (page) coordinates: the canvas occupies
|
||||
// `width`×`height` px starting at `offsetLeft`/`offsetTop`. Routing through
|
||||
// the canonical client→scene transform (rather than hand-rolling it) keeps
|
||||
// this in lockstep with how the rest of the editor centers content — see
|
||||
// `addElementsFromPasteOrLibrary`'s `position: "center"` handling. Note the
|
||||
// offsets cancel out of the math; a previous inline version dropped them on
|
||||
// the wrong side and inserted off-center in embedded hosts (C4 in tta.md).
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: appState.offsetLeft + appState.width / 2,
|
||||
clientY: appState.offsetTop + appState.height / 2,
|
||||
},
|
||||
appState,
|
||||
);
|
||||
|
||||
return { sceneX: x, sceneY: y };
|
||||
};
|
||||
|
||||
export const AI_GENERATED_ELEMENTS_KEY = "aiSidebarGenerationId";
|
||||
export const INTERMEDIATE_PREVIEW_ELEMENT_KEY = "intermediatePreviewElement";
|
||||
|
||||
/**
|
||||
* Whether the element is a live streaming-preview element — a real scene
|
||||
* element inserted with `CaptureUpdateAction.NEVER` while a generation is
|
||||
* still streaming. Such elements must never be persisted (hosts should filter
|
||||
* them from saves — see excalidraw-app's `getPersistableElements`).
|
||||
*/
|
||||
export const isIntermediatePreviewElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => element.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY] === true;
|
||||
|
||||
/**
|
||||
* Tombstones every non-deleted element tagged with one of the given
|
||||
* generation tags. Pure — returns the next elements array and whether
|
||||
* anything changed; the caller decides how to apply it to the scene.
|
||||
*/
|
||||
export const getElementsWithDeletedGenerationTags = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
generationTags: ReadonlySet<string>,
|
||||
): { elements: ExcalidrawElement[]; didChange: boolean } => {
|
||||
let didChange = false;
|
||||
const nextElements = elements.map((element) => {
|
||||
if (element.isDeleted) {
|
||||
return element;
|
||||
}
|
||||
const elementGenerationTag =
|
||||
element.customData?.[AI_GENERATED_ELEMENTS_KEY];
|
||||
if (
|
||||
typeof elementGenerationTag === "string" &&
|
||||
generationTags.has(elementGenerationTag)
|
||||
) {
|
||||
didChange = true;
|
||||
return newElementWith(element, { isDeleted: true });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
return { elements: nextElements, didChange };
|
||||
};
|
||||
|
||||
interface InsertSkeletonsOptions {
|
||||
targetCenter?: { x: number; y: number };
|
||||
generationId?: string;
|
||||
regenerateIds?: boolean;
|
||||
selectInsertedElements?: boolean;
|
||||
captureUpdate?: CaptureUpdateActionType;
|
||||
deleteGenerationTags?: readonly string[];
|
||||
intermediatePreviewElement?: boolean;
|
||||
}
|
||||
|
||||
export const fixBoundTextElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scene: Scene,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
// `restoreElements()` already repairs the `text.containerId -> container.boundElements`
|
||||
// direction. This pass only makes the inverse container-declared binding
|
||||
// authoritative when the payload already references the text from the container.
|
||||
for (const element of elements) {
|
||||
if (!element.boundElements?.length) {
|
||||
continue;
|
||||
}
|
||||
const boundTextId = element.boundElements.find(
|
||||
(boundElement) => boundElement.type === "text",
|
||||
)?.id;
|
||||
if (!boundTextId) {
|
||||
continue;
|
||||
}
|
||||
const boundTextElement = elementsMap.get(boundTextId);
|
||||
if (!boundTextElement || boundTextElement.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
boundTextElement.type === "text" &&
|
||||
boundTextElement.containerId !== element.id
|
||||
) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
containerId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of elements) {
|
||||
if (!isBoundToContainer(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (!container) {
|
||||
continue;
|
||||
}
|
||||
|
||||
redrawTextBoundingBox(element, container, scene);
|
||||
}
|
||||
};
|
||||
|
||||
const convertAISkeletonsToElements = (
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
|
||||
options?: { regenerateIds?: boolean },
|
||||
): readonly NonDeletedExcalidrawElement[] => {
|
||||
if (skeletons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const convertedElements = convertToExcalidrawElements([...skeletons], {
|
||||
regenerateIds: options?.regenerateIds ?? false,
|
||||
}) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
const normalizedElements = restoreElements(convertedElements, null, {
|
||||
repairBindings: true,
|
||||
}) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
return normalizeElbowArrows(normalizedElements);
|
||||
};
|
||||
|
||||
export const convertAISkeletonsToSceneElements = (
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
|
||||
app: AppClassProperties,
|
||||
options?: InsertSkeletonsOptions,
|
||||
): readonly NonDeletedExcalidrawElement[] => {
|
||||
if (skeletons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const elbowNormalizedElements = convertAISkeletonsToElements(skeletons, {
|
||||
regenerateIds: options?.regenerateIds ?? false,
|
||||
});
|
||||
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elbowNormalizedElements);
|
||||
const elementsCenterXRaw = (minX + maxX) / 2;
|
||||
const elementsCenterYRaw = (minY + maxY) / 2;
|
||||
|
||||
const { sceneX: viewportCenterSceneX, sceneY: viewportCenterSceneY } =
|
||||
getViewportCenter(app.state);
|
||||
|
||||
const destination = options?.targetCenter ?? {
|
||||
x: viewportCenterSceneX,
|
||||
y: viewportCenterSceneY,
|
||||
};
|
||||
|
||||
const elementsCenterX = Number.isFinite(elementsCenterXRaw)
|
||||
? elementsCenterXRaw
|
||||
: destination.x;
|
||||
const elementsCenterY = Number.isFinite(elementsCenterYRaw)
|
||||
? elementsCenterYRaw
|
||||
: destination.y;
|
||||
|
||||
const dx = destination.x - elementsCenterX;
|
||||
const dy = destination.y - elementsCenterY;
|
||||
|
||||
const generationId = options?.generationId;
|
||||
const isIntermediatePreview = options?.intermediatePreviewElement === true;
|
||||
|
||||
return elbowNormalizedElements.map((element) => {
|
||||
const nextCustomData =
|
||||
generationId || isIntermediatePreview
|
||||
? {
|
||||
...(element.customData ?? {}),
|
||||
...(generationId
|
||||
? { [AI_GENERATED_ELEMENTS_KEY]: generationId }
|
||||
: {}),
|
||||
...(isIntermediatePreview
|
||||
? { [INTERMEDIATE_PREVIEW_ELEMENT_KEY]: true }
|
||||
: {}),
|
||||
}
|
||||
: element.customData;
|
||||
|
||||
return {
|
||||
...element,
|
||||
x: element.x + dx,
|
||||
y: element.y + dy,
|
||||
customData: nextCustomData,
|
||||
};
|
||||
}) as readonly NonDeletedExcalidrawElement[];
|
||||
};
|
||||
|
||||
export const insertAISkeletons = (
|
||||
app: AppClassProperties,
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
|
||||
options?: InsertSkeletonsOptions,
|
||||
) => {
|
||||
const rawAdjustedElements = convertAISkeletonsToSceneElements(
|
||||
skeletons,
|
||||
app,
|
||||
options,
|
||||
);
|
||||
if (!rawAdjustedElements.length) {
|
||||
return [];
|
||||
}
|
||||
const deleteGenerationTagSet = options?.deleteGenerationTags?.length
|
||||
? new Set(options.deleteGenerationTags)
|
||||
: null;
|
||||
const existingElementsBase = deleteGenerationTagSet
|
||||
? app.scene.getElementsIncludingDeleted().map((element) => {
|
||||
if (element.isDeleted) {
|
||||
return element;
|
||||
}
|
||||
const elementGenerationTag =
|
||||
element.customData?.[AI_GENERATED_ELEMENTS_KEY];
|
||||
if (
|
||||
typeof elementGenerationTag === "string" &&
|
||||
deleteGenerationTagSet.has(elementGenerationTag)
|
||||
) {
|
||||
return newElementWith(element, { isDeleted: true });
|
||||
}
|
||||
return element;
|
||||
})
|
||||
: app.scene.getElementsIncludingDeleted();
|
||||
const existingElementsById = new Map(
|
||||
existingElementsBase.map((element) => [element.id, element]),
|
||||
);
|
||||
const adjustedElements = rawAdjustedElements.map((element) => {
|
||||
const existingElement = existingElementsById.get(element.id);
|
||||
if (existingElement && element.version <= existingElement.version) {
|
||||
return newElementWith(
|
||||
element,
|
||||
{ version: existingElement.version + 1 },
|
||||
true,
|
||||
);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
const adjustedElementIds = new Set(
|
||||
adjustedElements.map((element) => element.id),
|
||||
);
|
||||
const existingElements = existingElementsBase.filter(
|
||||
(element) => !adjustedElementIds.has(element.id),
|
||||
);
|
||||
const nextElements = [...existingElements, ...adjustedElements];
|
||||
|
||||
// We don't want to mutate the live scene before updateScene is applied, but we do
|
||||
// want any bound-text corrections to be included in the captured update.
|
||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||
fixBoundTextElements(adjustedElements, tempScene);
|
||||
|
||||
const appState = options?.selectInsertedElements
|
||||
? {
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
adjustedElements
|
||||
.filter((element) => !isBoundToContainer(element))
|
||||
.reduce((acc: Record<string, true>, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {}),
|
||||
app.state,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
app.syncActionResult({
|
||||
elements: nextElements,
|
||||
appState,
|
||||
captureUpdate: options?.captureUpdate ?? CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
return adjustedElements;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
describe("throttle", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invokes immediately on the leading edge", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 300);
|
||||
|
||||
throttled("a");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("parks calls inside the window and invokes the latest on the trailing edge", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 300);
|
||||
|
||||
throttled("a");
|
||||
throttled("b");
|
||||
throttled("c");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(299);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith("c");
|
||||
|
||||
// no extra trailing invocation afterwards
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("flush invokes a parked call immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 300);
|
||||
|
||||
throttled("a");
|
||||
throttled("b");
|
||||
throttled.flush();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith("b");
|
||||
|
||||
// flush with nothing parked is a no-op
|
||||
throttled.flush();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cancel drops the parked call and resets the window", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 300);
|
||||
|
||||
throttled("a");
|
||||
throttled("b");
|
||||
throttled.cancel();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// window was reset — the next call is a leading-edge immediate invoke
|
||||
throttled("c");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith("c");
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Shared leading + trailing edge throttle (tta_rewrite_final.md §2.4).
|
||||
*
|
||||
* - A call outside the window invokes immediately (leading edge).
|
||||
* - A call inside the window parks the *latest* args and schedules a trailing
|
||||
* invocation for when the window elapses — a parked update never has to wait
|
||||
* for the next call to render (M1: no more frozen canvas on provider
|
||||
* stalls).
|
||||
* - `flush()` invokes any parked call immediately.
|
||||
* - `cancel()` drops any parked call and fully resets the throttle, so the
|
||||
* next call is a leading-edge immediate invoke.
|
||||
*/
|
||||
export type ThrottledFunction<Args extends unknown[]> = {
|
||||
(...args: Args): void;
|
||||
flush: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const throttle = <Args extends unknown[]>(
|
||||
fn: (...args: Args) => void,
|
||||
wait: number,
|
||||
): ThrottledFunction<Args> => {
|
||||
let lastInvokeTime = -Infinity;
|
||||
let pendingArgs: Args | null = null;
|
||||
let trailingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearTrailingTimeout = () => {
|
||||
if (trailingTimeout !== null) {
|
||||
clearTimeout(trailingTimeout);
|
||||
trailingTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const invoke = (args: Args) => {
|
||||
pendingArgs = null;
|
||||
lastInvokeTime = Date.now();
|
||||
fn(...args);
|
||||
};
|
||||
|
||||
const throttled = (...args: Args) => {
|
||||
const remaining = wait - (Date.now() - lastInvokeTime);
|
||||
if (remaining <= 0) {
|
||||
clearTrailingTimeout();
|
||||
invoke(args);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingArgs = args;
|
||||
if (trailingTimeout === null) {
|
||||
trailingTimeout = setTimeout(() => {
|
||||
trailingTimeout = null;
|
||||
if (pendingArgs) {
|
||||
invoke(pendingArgs);
|
||||
}
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
|
||||
throttled.flush = () => {
|
||||
clearTrailingTimeout();
|
||||
if (pendingArgs) {
|
||||
invoke(pendingArgs);
|
||||
}
|
||||
};
|
||||
|
||||
throttled.cancel = () => {
|
||||
clearTrailingTimeout();
|
||||
pendingArgs = null;
|
||||
lastInvokeTime = -Infinity;
|
||||
};
|
||||
|
||||
return throttled;
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { AIRateLimitWarningDescriptor } from "../aiWarnings";
|
||||
|
||||
export type AIServerMessageRole = "user" | "assistant";
|
||||
export type AIAssistantLifecycleStatus =
|
||||
| "pending"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted";
|
||||
|
||||
export type AIStreamProgressPhase =
|
||||
| "starting"
|
||||
| "waiting"
|
||||
| "thinking"
|
||||
| "generating"
|
||||
| "finalizing";
|
||||
|
||||
export interface AIServerConversationMessage {
|
||||
role: AIServerMessageRole;
|
||||
content: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface AIGenerationRetryContext {
|
||||
reason: "user_not_happy" | "generation_error";
|
||||
avoidSimilarity?: boolean;
|
||||
retryAssistantMessageId?: string;
|
||||
}
|
||||
|
||||
export interface AIGenerateRequestPayload extends Record<string, unknown> {
|
||||
prompt: string;
|
||||
images?: string[];
|
||||
chatId?: string | null;
|
||||
retry?: AIGenerationRetryContext;
|
||||
}
|
||||
|
||||
export interface ExcalidrawServerPayloadBase {
|
||||
skeletons: ExcalidrawElementSkeleton[];
|
||||
}
|
||||
|
||||
export interface AIStreamPartialPayload extends ExcalidrawServerPayloadBase {
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export interface AIStreamStartedPayload {
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "pending">;
|
||||
updatedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface AIStreamMessagePayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AIStreamFinalPayload extends AIStreamPartialPayload {
|
||||
chatId?: string | null;
|
||||
turnId?: string | null;
|
||||
messageId?: string | null;
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "completed">;
|
||||
updatedAt?: number | null;
|
||||
/**
|
||||
* Provider finish reason, forwarded from the `done` frame. `"length"` /
|
||||
* `"content_filter"` mean the generation was truncated/blocked even though
|
||||
* it parsed — not a clean success.
|
||||
*/
|
||||
finishReason?: "stop" | "length" | "content_filter" | "tool_calls" | null;
|
||||
}
|
||||
|
||||
export const AI_ERRORS = {
|
||||
RATE_LIMIT: {
|
||||
code: 429,
|
||||
message: "Rate limit exceeded. Please wait before trying again.",
|
||||
},
|
||||
SERVER_ERROR: {
|
||||
code: 500,
|
||||
message: "An internal server error occurred. Please try again later.",
|
||||
},
|
||||
REQUEST_ERROR: {
|
||||
code: 400,
|
||||
message: "The request could not be processed due to invalid data.",
|
||||
},
|
||||
GENERATION_ERROR: {
|
||||
code: 422,
|
||||
message: "Failed to generate valid content.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AI_ERROR_CODE = typeof AI_ERRORS[keyof typeof AI_ERRORS]["code"];
|
||||
|
||||
export type StreamChunk =
|
||||
| {
|
||||
type: "started";
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "pending">;
|
||||
updatedAt?: number;
|
||||
}
|
||||
| {
|
||||
type: "message";
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "partial";
|
||||
skeletons: ExcalidrawElementSkeleton[];
|
||||
isComplete?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "done";
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "completed">;
|
||||
finishReason: "stop" | "length" | "content_filter" | "tool_calls" | null;
|
||||
skeletons: ExcalidrawElementSkeleton[];
|
||||
chatId?: string;
|
||||
turnId?: string;
|
||||
messageId?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
lifecycleStatus?: Extract<AIAssistantLifecycleStatus, "failed">;
|
||||
error: {
|
||||
code?: number;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserMessage = {
|
||||
role: "user";
|
||||
id: string; // stable, persisted (no re-minting on hydrate)
|
||||
content: string;
|
||||
images?: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The assistant generation lifecycle as a discriminated union — exactly one
|
||||
* variant at a time, patched with single `status =` assignments. Everything
|
||||
* else (spinner, terminal labels, retry-eligibility, elapsed display) is
|
||||
* derived at render from `kind`.
|
||||
*/
|
||||
export type AssistantStatus =
|
||||
| {
|
||||
kind: "streaming";
|
||||
phase: AIStreamProgressPhase;
|
||||
startedAt: number;
|
||||
/** Free server text (`message` frames) overriding the phase label. */
|
||||
statusText?: string;
|
||||
}
|
||||
| {
|
||||
kind: "done";
|
||||
elapsedMs: number;
|
||||
outcome: "generated" | "empty";
|
||||
/** Provider finishReason surfaced: truncated/blocked, partials kept. */
|
||||
warning?: "length" | "content_filter";
|
||||
}
|
||||
| { kind: "stopped"; elapsedMs: number; reason: "user" | "interrupted" }
|
||||
| {
|
||||
kind: "error";
|
||||
elapsedMs?: number;
|
||||
error: { code?: number; message: string };
|
||||
};
|
||||
|
||||
export type AssistantMessage = {
|
||||
role: "assistant";
|
||||
/**
|
||||
* Local generation id — stable across error-retries and persistence.
|
||||
*/
|
||||
id: string;
|
||||
createdAt: number;
|
||||
/** Reconciled once from `started`; metadata only (retry/truncate ids). */
|
||||
server?: { turnId: string; messageId: string };
|
||||
/**
|
||||
* Server id of the turn's last *successful* attempt — the only id the
|
||||
* server's retry lookup accepts (`current_message_id`). Survives failed
|
||||
* retries; absent when the turn never completed successfully.
|
||||
*/
|
||||
lastCompletedMessageId?: string;
|
||||
skeletons?: ReadonlyArray<ExcalidrawElementSkeleton>;
|
||||
status: AssistantStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session-scoped rate-limit warning bubble. Not persisted; the numbers live in
|
||||
* the rate-limits atom, not on the message.
|
||||
*/
|
||||
export type SystemWarningMessage = {
|
||||
role: "system";
|
||||
id: string;
|
||||
createdAt: number;
|
||||
variant: AIRateLimitWarningDescriptor["variant"];
|
||||
};
|
||||
|
||||
export type ChatMessage = UserMessage | AssistantMessage | SystemWarningMessage;
|
||||
|
||||
export type ChatConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export interface TTARateLimits {
|
||||
rateLimit: number;
|
||||
rateLimitRemaining: number;
|
||||
}
|
||||
|
||||
export type TTAChatScrollOptions = {
|
||||
keepElementTopVisible?: HTMLElement | null;
|
||||
behavior?: ScrollBehavior;
|
||||
};
|
||||
|
||||
export interface TTAPersistenceAdapter {
|
||||
loadChats(): Promise<ChatConversation[]>;
|
||||
saveChat(chat: ChatConversation): Promise<void>;
|
||||
deleteChat(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the empty-state intro area. Return `undefined` to use the built-in
|
||||
* guidance text.
|
||||
*/
|
||||
export type TTADialogRenderWelcomeScreen = (props: {
|
||||
rateLimits: TTARateLimits | null;
|
||||
}) => ReactNode | undefined;
|
||||
|
||||
/**
|
||||
* Return `undefined` to use the built-in warning/error rendering.
|
||||
* Receives a shared warning descriptor as the first argument.
|
||||
* Returning a node replaces the full message row.
|
||||
*/
|
||||
export type TTADialogRenderWarning = (
|
||||
warning: AIRateLimitWarningDescriptor,
|
||||
chatMessage: SystemWarningMessage,
|
||||
) => ReactNode | undefined;
|
||||
@@ -1,281 +0,0 @@
|
||||
import React from "react";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
evictAssistantPreviews,
|
||||
useAIAssistantPreview,
|
||||
} from "./useAIAssistantPreview";
|
||||
|
||||
import type { AssistantMessage } from "./types";
|
||||
|
||||
const { exportToSvgMock, mockApp } = vi.hoisted(() => ({
|
||||
exportToSvgMock: vi.fn(),
|
||||
// stable identity — `app` is an effect dependency in the hook (the real
|
||||
// useApp returns the context-stable App instance)
|
||||
mockApp: { files: {} },
|
||||
}));
|
||||
|
||||
vi.mock("@excalidraw/utils/export", () => ({
|
||||
exportToSvg: exportToSvgMock,
|
||||
}));
|
||||
|
||||
vi.mock("../components/App", () => ({
|
||||
useApp: () => mockApp,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useAppStateValue", () => ({
|
||||
useAppStateValue: () => "light",
|
||||
}));
|
||||
|
||||
vi.mock("./insertAISkeletons", () => ({
|
||||
convertAISkeletonsToSceneElements: (
|
||||
skeletons: ReadonlyArray<{ id?: string }>,
|
||||
) =>
|
||||
skeletons.map((skeleton, index) => ({
|
||||
id: skeleton.id ?? `element-${index}`,
|
||||
type: "rectangle",
|
||||
isDeleted: false,
|
||||
})),
|
||||
fixBoundTextElements: () => {},
|
||||
}));
|
||||
|
||||
const skeleton = (id: string): ExcalidrawElementSkeleton =>
|
||||
({
|
||||
type: "rectangle",
|
||||
id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
} as ExcalidrawElementSkeleton);
|
||||
|
||||
const completedMessage = (
|
||||
id: string,
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton> = [
|
||||
skeleton(`${id}-skeleton`),
|
||||
],
|
||||
): AssistantMessage => ({
|
||||
role: "assistant",
|
||||
id,
|
||||
createdAt: 1,
|
||||
skeletons,
|
||||
status: { kind: "done", elapsedMs: 10, outcome: "generated" },
|
||||
});
|
||||
|
||||
const streamingMessage = (
|
||||
id: string,
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
|
||||
): AssistantMessage => ({
|
||||
role: "assistant",
|
||||
id,
|
||||
createdAt: 1,
|
||||
skeletons,
|
||||
status: { kind: "streaming", phase: "generating", startedAt: 1 },
|
||||
});
|
||||
|
||||
type PreviewHookValue = ReturnType<typeof useAIAssistantPreview>;
|
||||
|
||||
const Harness = ({
|
||||
message,
|
||||
enabled,
|
||||
onHook,
|
||||
}: {
|
||||
message: AssistantMessage;
|
||||
enabled?: boolean;
|
||||
onHook: (value: PreviewHookValue) => void;
|
||||
}) => {
|
||||
onHook(
|
||||
useAIAssistantPreview(
|
||||
message,
|
||||
enabled === undefined ? undefined : { enabled },
|
||||
),
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderPreview = (message: AssistantMessage, enabled?: boolean) => {
|
||||
const hookRef: { current: PreviewHookValue | null } = { current: null };
|
||||
const utils = render(
|
||||
<Harness
|
||||
message={message}
|
||||
enabled={enabled}
|
||||
onHook={(value) => {
|
||||
hookRef.current = value;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return { hookRef, ...utils };
|
||||
};
|
||||
|
||||
/** Mounts a completed message so its preview lands in the module cache. */
|
||||
const mountUntilDone = async (message: AssistantMessage) => {
|
||||
const { hookRef, unmount } = renderPreview(message);
|
||||
await waitFor(() => {
|
||||
expect(hookRef.current!.status).toBe("done");
|
||||
});
|
||||
unmount();
|
||||
};
|
||||
|
||||
describe("useAIAssistantPreview", () => {
|
||||
beforeEach(() => {
|
||||
exportToSvgMock.mockClear();
|
||||
exportToSvgMock.mockImplementation(async () =>
|
||||
document.createElementNS("http://www.w3.org/2000/svg", "svg"),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("serves a completed message from the cache without re-rendering", async () => {
|
||||
const message = completedMessage("cache-hit");
|
||||
await mountUntilDone(message);
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// same render key + same skeletons reference → synchronously done from
|
||||
// the initializer, no new export
|
||||
const { hookRef, unmount } = renderPreview(message);
|
||||
expect(hookRef.current!.status).toBe("done");
|
||||
expect(hookRef.current!.previewSvg).toMatch(/^data:image\/svg\+xml,/);
|
||||
await act(async () => {});
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("caps the cache at 32 entries, evicting the least-recently-used one", async () => {
|
||||
const messages = Array.from({ length: 32 }, (_, index) =>
|
||||
completedMessage(`lru-${index}`),
|
||||
);
|
||||
for (const message of messages) {
|
||||
await mountUntilDone(message);
|
||||
}
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(32);
|
||||
|
||||
// touch lru-0 (cache hit) so lru-1 becomes the least-recently-used entry
|
||||
const touched = renderPreview(messages[0]);
|
||||
expect(touched.hookRef.current!.status).toBe("done");
|
||||
await act(async () => {});
|
||||
touched.unmount();
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(32);
|
||||
|
||||
// 33rd entry exceeds the bound → evicts lru-1, not the freshly-used lru-0
|
||||
await mountUntilDone(completedMessage("lru-32"));
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(33);
|
||||
|
||||
const kept = renderPreview(messages[0]);
|
||||
expect(kept.hookRef.current!.status).toBe("done");
|
||||
await act(async () => {});
|
||||
kept.unmount();
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(33);
|
||||
|
||||
const evicted = renderPreview(messages[1]);
|
||||
expect(evicted.hookRef.current!.status).toBe("loading");
|
||||
await waitFor(() => {
|
||||
expect(evicted.hookRef.current!.status).toBe("done");
|
||||
});
|
||||
evicted.unmount();
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(34);
|
||||
});
|
||||
|
||||
it("evictAssistantPreviews drops the given messages' cached previews", async () => {
|
||||
const kept = completedMessage("evict-kept");
|
||||
const dropped = completedMessage("evict-dropped");
|
||||
await mountUntilDone(kept);
|
||||
await mountUntilDone(dropped);
|
||||
exportToSvgMock.mockClear();
|
||||
|
||||
evictAssistantPreviews(["evict-dropped", "evict-not-cached"]);
|
||||
|
||||
const keptRemount = renderPreview(kept);
|
||||
expect(keptRemount.hookRef.current!.status).toBe("done");
|
||||
await act(async () => {});
|
||||
keptRemount.unmount();
|
||||
expect(exportToSvgMock).not.toHaveBeenCalled();
|
||||
|
||||
const droppedRemount = renderPreview(dropped);
|
||||
expect(droppedRemount.hookRef.current!.status).toBe("loading");
|
||||
await waitFor(() => {
|
||||
expect(droppedRemount.hookRef.current!.status).toBe("done");
|
||||
});
|
||||
droppedRemount.unmount();
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("enabled: false does no render work and reports idle / cached / unavailable", async () => {
|
||||
const message = completedMessage("disabled");
|
||||
|
||||
// uncached → idle, no export
|
||||
const idle = renderPreview(message, false);
|
||||
expect(idle.hookRef.current!.status).toBe("idle");
|
||||
await act(async () => {});
|
||||
expect(idle.hookRef.current!.status).toBe("idle");
|
||||
expect(exportToSvgMock).not.toHaveBeenCalled();
|
||||
idle.unmount();
|
||||
|
||||
// cached → served as done, still no render work
|
||||
await mountUntilDone(message);
|
||||
exportToSvgMock.mockClear();
|
||||
const cached = renderPreview(message, false);
|
||||
expect(cached.hookRef.current!.status).toBe("done");
|
||||
expect(cached.hookRef.current!.previewSvg).toBeTruthy();
|
||||
await act(async () => {});
|
||||
expect(exportToSvgMock).not.toHaveBeenCalled();
|
||||
cached.unmount();
|
||||
|
||||
// no skeletons → unavailable
|
||||
const empty = renderPreview(
|
||||
{ ...completedMessage("disabled-empty"), skeletons: [] },
|
||||
false,
|
||||
);
|
||||
expect(empty.hookRef.current!.status).toBe("unavailable");
|
||||
empty.unmount();
|
||||
});
|
||||
|
||||
it("throttles streaming renders with a trailing flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstChunk = [skeleton("chunk-1")];
|
||||
const secondChunk = [skeleton("chunk-1"), skeleton("chunk-2")];
|
||||
|
||||
const hookRef: { current: PreviewHookValue | null } = { current: null };
|
||||
const onHook = (value: PreviewHookValue) => {
|
||||
hookRef.current = value;
|
||||
};
|
||||
const { rerender, unmount } = render(
|
||||
<Harness
|
||||
message={streamingMessage("streaming", firstChunk)}
|
||||
onHook={onHook}
|
||||
/>,
|
||||
);
|
||||
|
||||
// leading edge: the first chunk renders immediately
|
||||
await act(async () => {});
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
|
||||
expect(hookRef.current!.status).toBe("done");
|
||||
|
||||
// a chunk inside the window parks (previous preview stays visible)
|
||||
rerender(
|
||||
<Harness
|
||||
message={streamingMessage("streaming", secondChunk)}
|
||||
onHook={onHook}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(1);
|
||||
expect(hookRef.current!.status).toBe("loading");
|
||||
expect(hookRef.current!.previewSvg).toBeTruthy();
|
||||
|
||||
// trailing edge flushes the parked chunk
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(exportToSvgMock).toHaveBeenCalledTimes(2);
|
||||
expect(hookRef.current!.status).toBe("done");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Scene } from "@excalidraw/element";
|
||||
import { exportToSvg } from "@excalidraw/utils/export";
|
||||
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
|
||||
|
||||
import { useApp } from "../components/App";
|
||||
import { useAppStateValue } from "../hooks/useAppStateValue";
|
||||
|
||||
import {
|
||||
convertAISkeletonsToSceneElements,
|
||||
fixBoundTextElements,
|
||||
} from "./insertAISkeletons";
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
import type { AssistantMessage } from "./types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const ASSISTANT_PREVIEW_RENDER_THROTTLE_DELAY = 300;
|
||||
|
||||
/** LRU bound for the module-level preview cache (M7). */
|
||||
const ASSISTANT_PREVIEW_CACHE_MAX_ENTRIES = 32;
|
||||
|
||||
export type AIAssistantPreviewStatus =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "done"
|
||||
| "unavailable";
|
||||
|
||||
type AIAssistantPreviewState = {
|
||||
previewSvg: string | null;
|
||||
status: AIAssistantPreviewStatus;
|
||||
};
|
||||
|
||||
type AssistantPreviewCacheEntry = {
|
||||
renderKey: string;
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>;
|
||||
previewSvg: string;
|
||||
};
|
||||
|
||||
type AssistantPreviewRenderRequest = {
|
||||
app: AppClassProperties;
|
||||
theme: AppState["theme"];
|
||||
messageId: string;
|
||||
renderKey: string;
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>;
|
||||
};
|
||||
|
||||
type UseAIAssistantPreviewOptions = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Preview cache keyed by the (persistence-stable) local message id. The Map's
|
||||
* insertion order doubles as recency order — hits and writes re-insert their
|
||||
* entry, so the first key is always the least-recently-used one.
|
||||
*/
|
||||
const assistantPreviewCache = new Map<string, AssistantPreviewCacheEntry>();
|
||||
|
||||
const getCachedPreview = (
|
||||
messageId: string,
|
||||
renderKey: string,
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>,
|
||||
) => {
|
||||
const cached = assistantPreviewCache.get(messageId);
|
||||
// streaming entries self-invalidate on new chunks: the skeletons identity
|
||||
// changes, so only the exact rendered partial ever hits
|
||||
if (cached?.renderKey === renderKey && cached.skeletons === skeletons) {
|
||||
assistantPreviewCache.delete(messageId);
|
||||
assistantPreviewCache.set(messageId, cached);
|
||||
return cached.previewSvg;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setCachedPreview = (
|
||||
messageId: string,
|
||||
entry: AssistantPreviewCacheEntry,
|
||||
) => {
|
||||
assistantPreviewCache.delete(messageId);
|
||||
assistantPreviewCache.set(messageId, entry);
|
||||
if (assistantPreviewCache.size > ASSISTANT_PREVIEW_CACHE_MAX_ENTRIES) {
|
||||
const leastRecentlyUsed = assistantPreviewCache.keys().next().value;
|
||||
if (leastRecentlyUsed !== undefined) {
|
||||
assistantPreviewCache.delete(leastRecentlyUsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Drops cached previews for the given messages — called on chat delete (M7). */
|
||||
export const evictAssistantPreviews = (messageIds: Iterable<string>) => {
|
||||
for (const messageId of messageIds) {
|
||||
assistantPreviewCache.delete(messageId);
|
||||
}
|
||||
};
|
||||
|
||||
const isDarkTheme = (theme: AppState["theme"]) => theme === "dark";
|
||||
|
||||
/** State updater that keeps the previous object identity when values match. */
|
||||
const toPreviewState =
|
||||
(
|
||||
previewSvg: string | null,
|
||||
status: AIAssistantPreviewStatus,
|
||||
): ((prev: AIAssistantPreviewState) => AIAssistantPreviewState) =>
|
||||
(prev) =>
|
||||
prev.previewSvg === previewSvg && prev.status === status
|
||||
? prev
|
||||
: { previewSvg, status };
|
||||
|
||||
const getRenderKey = (message: AssistantMessage, theme: AppState["theme"]) =>
|
||||
[
|
||||
message.id,
|
||||
theme,
|
||||
message.status.kind === "streaming" ? "streaming" : "complete",
|
||||
].join(":");
|
||||
|
||||
export const renderAIAssistantPreviewDataUrl = async ({
|
||||
app,
|
||||
skeletons,
|
||||
theme,
|
||||
}: {
|
||||
app: AppClassProperties;
|
||||
skeletons: ReadonlyArray<ExcalidrawElementSkeleton>;
|
||||
theme: AppState["theme"];
|
||||
}) => {
|
||||
if (!skeletons.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const elements = convertAISkeletonsToSceneElements(skeletons, app, {
|
||||
targetCenter: { x: 0, y: 0 },
|
||||
});
|
||||
if (!elements.length) {
|
||||
console.warn("AI Sidebar: no elements generated from skeletons");
|
||||
return null;
|
||||
}
|
||||
|
||||
const scene = new Scene(elements, { skipValidation: true });
|
||||
fixBoundTextElements(elements, scene);
|
||||
|
||||
if (typeof window === "undefined" || typeof XMLSerializer === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const svgElement = await exportToSvg({
|
||||
elements,
|
||||
appState: {
|
||||
exportBackground: false,
|
||||
exportWithDarkMode: isDarkTheme(theme),
|
||||
},
|
||||
files: app.files,
|
||||
renderEmbeddables: false,
|
||||
skipInliningFonts: true,
|
||||
});
|
||||
const serialized = new XMLSerializer().serializeToString(svgElement);
|
||||
return `data:image/svg+xml,${encodeURIComponent(serialized)}`;
|
||||
} catch (error) {
|
||||
console.warn("[AI Chat] Failed to render preview", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAIAssistantPreview = (
|
||||
message: AssistantMessage,
|
||||
options: UseAIAssistantPreviewOptions = {},
|
||||
): AIAssistantPreviewState => {
|
||||
const app = useApp();
|
||||
const theme = useAppStateValue("theme");
|
||||
const enabled = options.enabled ?? true;
|
||||
const skeletons = message.skeletons;
|
||||
const isStreaming = message.status.kind === "streaming";
|
||||
const renderKey = getRenderKey(message, theme);
|
||||
|
||||
const [previewState, setPreviewState] = useState<AIAssistantPreviewState>(
|
||||
() => {
|
||||
// NOTE errored messages may still carry partial skeletons streamed
|
||||
// before the failure (C2 in tta.md) — render them so the salvaged
|
||||
// partial result stays previewable alongside the error.
|
||||
if (!skeletons?.length) {
|
||||
return { previewSvg: null, status: "unavailable" };
|
||||
}
|
||||
const cachedPreview = getCachedPreview(message.id, renderKey, skeletons);
|
||||
if (cachedPreview) {
|
||||
return { previewSvg: cachedPreview, status: "done" };
|
||||
}
|
||||
return { previewSvg: null, status: enabled ? "loading" : "idle" };
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Epoch token: the render key the hook currently wants on screen (null when
|
||||
* idle/unavailable). An async render result only lands — in state *and* in
|
||||
* the cache — while it still matches; `renderSeq` additionally drops it once
|
||||
* a newer render has *started*, so a slow stale export never clobbers a
|
||||
* newer one.
|
||||
*/
|
||||
const activeRenderKeyRef = useRef<string | null>(null);
|
||||
const renderSeqRef = useRef(0);
|
||||
|
||||
const throttledRender = useMemo(
|
||||
() =>
|
||||
throttle((request: AssistantPreviewRenderRequest) => {
|
||||
if (activeRenderKeyRef.current !== request.renderKey) {
|
||||
// parked trailing render whose render key was superseded
|
||||
return;
|
||||
}
|
||||
const seq = ++renderSeqRef.current;
|
||||
void renderAIAssistantPreviewDataUrl(request).then((previewSvg) => {
|
||||
if (
|
||||
renderSeqRef.current !== seq ||
|
||||
activeRenderKeyRef.current !== request.renderKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (previewSvg) {
|
||||
setCachedPreview(request.messageId, {
|
||||
renderKey: request.renderKey,
|
||||
skeletons: request.skeletons,
|
||||
previewSvg,
|
||||
});
|
||||
setPreviewState(toPreviewState(previewSvg, "done"));
|
||||
} else {
|
||||
setPreviewState(toPreviewState(null, "unavailable"));
|
||||
}
|
||||
});
|
||||
}, ASSISTANT_PREVIEW_RENDER_THROTTLE_DELAY),
|
||||
[],
|
||||
);
|
||||
|
||||
// NOTE deliberately no effect cleanup: canceling the throttle between runs
|
||||
// would drop the parked trailing render on every streaming chunk. Stale
|
||||
// parked/in-flight renders self-drop via the epoch checks above instead
|
||||
// (at worst one parked render completes after unmount and warms the cache).
|
||||
useEffect(() => {
|
||||
if (!skeletons?.length) {
|
||||
activeRenderKeyRef.current = null;
|
||||
renderSeqRef.current += 1;
|
||||
throttledRender.cancel();
|
||||
setPreviewState(toPreviewState(null, "unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPreview = getCachedPreview(message.id, renderKey, skeletons);
|
||||
|
||||
if (!enabled) {
|
||||
// offscreen history rows (IntersectionObserver lazy rendering): no
|
||||
// render work — fall back to the cached preview or stay idle
|
||||
activeRenderKeyRef.current = null;
|
||||
renderSeqRef.current += 1;
|
||||
throttledRender.cancel();
|
||||
setPreviewState(
|
||||
cachedPreview
|
||||
? toPreviewState(cachedPreview, "done")
|
||||
: toPreviewState(null, "idle"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeRenderKeyRef.current = renderKey;
|
||||
|
||||
if (cachedPreview) {
|
||||
renderSeqRef.current += 1;
|
||||
throttledRender.cancel();
|
||||
setPreviewState(toPreviewState(cachedPreview, "done"));
|
||||
return;
|
||||
}
|
||||
|
||||
// keep the previous preview visible while the replacement renders
|
||||
setPreviewState((prev) =>
|
||||
prev.status === "loading"
|
||||
? prev
|
||||
: { previewSvg: prev.previewSvg, status: "loading" },
|
||||
);
|
||||
|
||||
const request: AssistantPreviewRenderRequest = {
|
||||
app,
|
||||
theme,
|
||||
messageId: message.id,
|
||||
renderKey,
|
||||
skeletons,
|
||||
};
|
||||
if (isStreaming) {
|
||||
throttledRender(request);
|
||||
} else {
|
||||
// terminal renders are immediate: cancel() resets the throttle window,
|
||||
// so the call below invokes on its leading edge
|
||||
throttledRender.cancel();
|
||||
throttledRender(request);
|
||||
}
|
||||
}, [
|
||||
app,
|
||||
enabled,
|
||||
isStreaming,
|
||||
message.id,
|
||||
renderKey,
|
||||
skeletons,
|
||||
theme,
|
||||
throttledRender,
|
||||
]);
|
||||
|
||||
return previewState;
|
||||
};
|
||||
@@ -1,340 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAIStreamingLifecycle } from "./useAIStreamingLifecycle";
|
||||
import { useCanvasDraft } from "./useCanvasDraft";
|
||||
|
||||
import type { TTAStreamFetchResult } from "./client";
|
||||
import type { AssistantMessage, ChatMessage } from "./types";
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
const streamingStatusOf = (message: AssistantMessage) => {
|
||||
if (message.status.kind !== "streaming") {
|
||||
throw new Error(
|
||||
`expected a streaming status, got "${message.status.kind}"`,
|
||||
);
|
||||
}
|
||||
return message.status;
|
||||
};
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((_resolve) => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const createMockApp = () => {
|
||||
let elements: unknown[] = [];
|
||||
|
||||
const updateScene = vi.fn(({ elements: nextElements }) => {
|
||||
elements = nextElements;
|
||||
});
|
||||
const syncActionResult = vi.fn(({ elements: nextElements }) => {
|
||||
elements = nextElements;
|
||||
});
|
||||
|
||||
return {
|
||||
api: {
|
||||
updateScene,
|
||||
},
|
||||
state: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
zoom: { value: 1 },
|
||||
selectedElementIds: {},
|
||||
},
|
||||
scene: {
|
||||
getNonDeletedElements: () =>
|
||||
elements.filter(
|
||||
(element) => !(element as { isDeleted?: boolean }).isDeleted,
|
||||
),
|
||||
getElementsIncludingDeleted: () => elements,
|
||||
},
|
||||
syncActionResult,
|
||||
} as unknown as AppClassProperties;
|
||||
};
|
||||
|
||||
const TestHarness = ({
|
||||
streamResult,
|
||||
streamFetch: streamFetchProp,
|
||||
onRateLimitInfo,
|
||||
onMessagesChange,
|
||||
}: {
|
||||
streamResult?: TTAStreamFetchResult;
|
||||
streamFetch?: ReturnType<typeof vi.fn>;
|
||||
onRateLimitInfo: (rateLimitInfo: {
|
||||
rateLimit?: number | null;
|
||||
rateLimitRemaining?: number | null;
|
||||
}) => void;
|
||||
/** Receives the raw (non-serialized) message array on every change. */
|
||||
onMessagesChange?: (messages: ChatMessage[]) => void;
|
||||
}) => {
|
||||
const [app] = useState(createMockApp);
|
||||
const [streamFetch] = useState(
|
||||
() => streamFetchProp ?? vi.fn().mockResolvedValue(streamResult),
|
||||
);
|
||||
const [applyServerChatMetadata] = useState(() => vi.fn());
|
||||
const didGenerateRef = useRef(false);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "hello",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
createdAt: 2,
|
||||
status: { kind: "streaming", phase: "starting", startedAt: 2 },
|
||||
},
|
||||
]);
|
||||
|
||||
const canvasDraft = useCanvasDraft({ app });
|
||||
|
||||
const lifecycle = useAIStreamingLifecycle({
|
||||
chatMessages,
|
||||
setChatMessages,
|
||||
applyServerChatMetadata,
|
||||
canvasDraft,
|
||||
streamFetch,
|
||||
onRateLimitInfo,
|
||||
});
|
||||
const { generateResponse } = lifecycle;
|
||||
|
||||
useEffect(() => {
|
||||
onMessagesChange?.(chatMessages);
|
||||
}, [chatMessages, onMessagesChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didGenerateRef.current) {
|
||||
return;
|
||||
}
|
||||
didGenerateRef.current = true;
|
||||
void generateResponse("assistant-1", { prompt: "hello" });
|
||||
}, [generateResponse]);
|
||||
|
||||
return (
|
||||
<pre data-testid="messages">{JSON.stringify(chatMessages, null, 2)}</pre>
|
||||
);
|
||||
};
|
||||
|
||||
describe("useAIStreamingLifecycle", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("adds a rate-limit warning when a successful response exhausts the quota", async () => {
|
||||
const onRateLimitInfo = vi.fn();
|
||||
|
||||
render(
|
||||
<TestHarness
|
||||
onRateLimitInfo={onRateLimitInfo}
|
||||
streamResult={{
|
||||
rateLimit: 100,
|
||||
rateLimitRemaining: 0,
|
||||
finalPayload: {
|
||||
skeletons: [],
|
||||
isComplete: true,
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
lifecycleStatus: "completed",
|
||||
},
|
||||
error: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
JSON.parse(screen.getByTestId("messages").textContent!),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
|
||||
expect(onRateLimitInfo).toHaveBeenCalledWith({
|
||||
rateLimit: 100,
|
||||
rateLimitRemaining: 0,
|
||||
});
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
server: { turnId: "turn-1", messageId: "message-1" },
|
||||
lastCompletedMessageId: "message-1",
|
||||
status: {
|
||||
kind: "done",
|
||||
outcome: "empty",
|
||||
elapsedMs: expect.any(Number),
|
||||
},
|
||||
});
|
||||
// session-scoped system warning bubble — the rate-limit numbers live in
|
||||
// the rate-limits atom, not on the message
|
||||
expect(messages[2]).toMatchObject({
|
||||
role: "system",
|
||||
variant: "messageLimitExceeded",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the streaming label until the status genuinely changes (keep-alive chunks are no-ops)", async () => {
|
||||
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
|
||||
const statusText = "Something went wrong. Retrying...";
|
||||
const keepAliveGate = deferred();
|
||||
const contentGate = deferred();
|
||||
const doneGate = deferred();
|
||||
const streamFetch = vi.fn().mockImplementation(async (options) => {
|
||||
options.onStarted?.({
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
});
|
||||
await keepAliveGate.promise;
|
||||
// empty keep-alive frames around a server `message` frame — the server
|
||||
// emits these continuously, and they must not replace the status
|
||||
// object (no-op) nor wipe the label
|
||||
options.onChunk?.({ skeletons: [], isComplete: false });
|
||||
options.onChunk?.({ skeletons: [], isComplete: false });
|
||||
options.onMessage?.({ message: statusText });
|
||||
options.onChunk?.({ skeletons: [], isComplete: false });
|
||||
await contentGate.promise;
|
||||
// real content — the label downgrade is legitimate progress
|
||||
options.onChunk?.({ skeletons: [skeleton], isComplete: false });
|
||||
await doneGate.promise;
|
||||
return {
|
||||
finalPayload: {
|
||||
skeletons: [skeleton],
|
||||
isComplete: true,
|
||||
chatId: "chat-1",
|
||||
turnId: "turn-1",
|
||||
messageId: "message-1",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
const snapshots: ChatMessage[][] = [];
|
||||
render(
|
||||
<TestHarness
|
||||
onRateLimitInfo={vi.fn()}
|
||||
streamFetch={streamFetch}
|
||||
onMessagesChange={(messages) => snapshots.push(messages)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
expect(messages[1].status).toMatchObject({
|
||||
kind: "streaming",
|
||||
phase: "generating",
|
||||
});
|
||||
});
|
||||
const startedAt = streamingStatusOf(
|
||||
snapshots.at(-1)![1] as AssistantMessage,
|
||||
).startedAt;
|
||||
|
||||
keepAliveGate.resolve();
|
||||
|
||||
// the server statusText set between keep-alive chunks survives them
|
||||
await waitFor(() => {
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
expect(messages[1].status).toMatchObject({
|
||||
kind: "streaming",
|
||||
phase: "finalizing",
|
||||
statusText,
|
||||
});
|
||||
});
|
||||
|
||||
// the unlabeled keep-alives before the `message` frame were complete
|
||||
// no-ops: every "generating, no label" render shares one message object
|
||||
const unlabeledGenerating = snapshots
|
||||
.map((messages) => messages[1] as AssistantMessage)
|
||||
.filter(
|
||||
(message) =>
|
||||
message.status.kind === "streaming" &&
|
||||
message.status.phase === "generating" &&
|
||||
!message.status.statusText,
|
||||
);
|
||||
expect(unlabeledGenerating.length).toBeGreaterThan(0);
|
||||
expect(new Set(unlabeledGenerating).size).toBe(1);
|
||||
// startedAt never resets mid-stream — the elapsed ticker depends on it
|
||||
expect(
|
||||
streamingStatusOf(snapshots.at(-1)![1] as AssistantMessage).startedAt,
|
||||
).toBe(startedAt);
|
||||
|
||||
contentGate.resolve();
|
||||
|
||||
// a renderable chunk is a real change: back to the unlabeled generating
|
||||
// phase, with the partial skeletons on the message
|
||||
await waitFor(() => {
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
expect(messages[1].skeletons).toEqual([skeleton]);
|
||||
expect(messages[1].status).toMatchObject({
|
||||
kind: "streaming",
|
||||
phase: "generating",
|
||||
});
|
||||
expect(messages[1].status.statusText).toBeUndefined();
|
||||
});
|
||||
|
||||
doneGate.resolve();
|
||||
|
||||
await waitFor(() => {
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
expect(messages[1].status).toMatchObject({
|
||||
kind: "done",
|
||||
outcome: "generated",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps streamed partial skeletons on the message when the stream is interrupted", async () => {
|
||||
const onRateLimitInfo = vi.fn();
|
||||
const skeleton = { type: "rectangle", x: 0, y: 0, width: 10, height: 10 };
|
||||
const streamFetch = vi.fn().mockImplementation(async (options) => {
|
||||
// Deliberately no onStarted: the canvas draft is keyed by the LOCAL
|
||||
// generation id, so the chunk renders even before `started` arrives.
|
||||
options.onChunk?.({ skeletons: [skeleton], isComplete: false });
|
||||
return {
|
||||
error: {
|
||||
code: 1002,
|
||||
message: "Connection interrupted before the response completed",
|
||||
lifecycleStatus: "failed",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<TestHarness
|
||||
onRateLimitInfo={onRateLimitInfo}
|
||||
streamFetch={streamFetch}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
expect(messages[1].status.kind).toBe("error");
|
||||
});
|
||||
|
||||
const messages = JSON.parse(screen.getByTestId("messages").textContent!);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
skeletons: [skeleton],
|
||||
status: {
|
||||
kind: "error",
|
||||
elapsedMs: expect.any(Number),
|
||||
error: { code: 1002 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,438 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { randomId } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
AI_ERRORS,
|
||||
type AIGenerateRequestPayload,
|
||||
type AIStreamProgressPhase,
|
||||
type AssistantMessage,
|
||||
type AssistantStatus,
|
||||
type ChatMessage,
|
||||
} from "./types";
|
||||
import { getAIChatErrorCode, withAIChatErrorMeta } from "./chatErrors";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import type { CanvasDraft } from "./useCanvasDraft";
|
||||
import type { TTATransportAdapter } from "./client";
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error instanceof Error && error.name === "AbortError";
|
||||
|
||||
const isHandledStreamErrorCode = (errorCode?: number) =>
|
||||
errorCode === AI_ERRORS.SERVER_ERROR.code ||
|
||||
errorCode === AI_ERRORS.RATE_LIMIT.code ||
|
||||
errorCode === AI_ERRORS.REQUEST_ERROR.code ||
|
||||
errorCode === AI_ERRORS.GENERATION_ERROR.code;
|
||||
|
||||
const assistantMessageHasError = (
|
||||
messages: ChatMessage[],
|
||||
assistantId: string,
|
||||
) => {
|
||||
const message = messages.find((candidate) => candidate.id === assistantId);
|
||||
return message?.role === "assistant" && message.status.kind === "error";
|
||||
};
|
||||
|
||||
type UseAIStreamingLifecycleOptions = {
|
||||
chatMessages: ChatMessage[];
|
||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
applyServerChatMetadata: (metadata: {
|
||||
chatId?: string | null;
|
||||
updatedAt?: number | null;
|
||||
}) => void;
|
||||
canvasDraft: CanvasDraft;
|
||||
streamFetch: TTATransportAdapter["stream"];
|
||||
onRateLimitInfo?: (rateLimitInfo: {
|
||||
rateLimit?: number | null;
|
||||
rateLimitRemaining?: number | null;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const isExhaustedRateLimit = (rateLimitRemaining?: number | null) =>
|
||||
typeof rateLimitRemaining === "number" &&
|
||||
Number.isFinite(rateLimitRemaining) &&
|
||||
rateLimitRemaining === 0;
|
||||
|
||||
const STREAM_IDLE_STATUS_DELAY = 5000;
|
||||
|
||||
const getElapsedMs = (startedAt: number) => Math.max(0, Date.now() - startedAt);
|
||||
|
||||
/**
|
||||
* A streaming status the user can currently read: free server text
|
||||
* (`message` frames) or an idle-timer phase with a built-in label.
|
||||
* `starting`/`waiting`/`generating` render spinner-only.
|
||||
*/
|
||||
const isLabeledStreamingStatus = (
|
||||
status: Extract<AssistantStatus, { kind: "streaming" }>,
|
||||
) =>
|
||||
Boolean(status.statusText) ||
|
||||
status.phase === "thinking" ||
|
||||
status.phase === "finalizing";
|
||||
|
||||
export const useAIStreamingLifecycle = ({
|
||||
chatMessages,
|
||||
setChatMessages,
|
||||
applyServerChatMetadata,
|
||||
canvasDraft,
|
||||
streamFetch,
|
||||
onRateLimitInfo,
|
||||
}: UseAIStreamingLifecycleOptions) => {
|
||||
const activeStreamAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const stopRequestedRef = useRef(false);
|
||||
const chatMessagesRef = useRef(chatMessages);
|
||||
|
||||
chatMessagesRef.current = chatMessages;
|
||||
|
||||
const patchAssistantMessage = useCallback(
|
||||
(assistantId: string, patch: Partial<AssistantMessage>) => {
|
||||
setChatMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantId && message.role === "assistant"
|
||||
? { ...message, ...patch }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setChatMessages],
|
||||
);
|
||||
|
||||
const appendRateLimitWarningMessage = useCallback(() => {
|
||||
setChatMessages((prev) => {
|
||||
const lastMessage = prev.at(-1);
|
||||
// dedupe on repeat — the rate-limit numbers live in the atom, so a
|
||||
// second exhausted response has nothing new to say
|
||||
if (
|
||||
lastMessage?.role === "system" &&
|
||||
lastMessage.variant === "messageLimitExceeded"
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `system-rate-limit-${randomId()}`,
|
||||
role: "system",
|
||||
createdAt: Date.now(),
|
||||
variant: "messageLimitExceeded",
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [setChatMessages]);
|
||||
|
||||
const generateResponse = useCallback(
|
||||
async (assistantId: string, payload: AIGenerateRequestPayload) => {
|
||||
let activeTurnId: string | null = null;
|
||||
let activeMessageId: string | null = null;
|
||||
let idleStatusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let hasReceivedRenderableChunk = false;
|
||||
const generationStartedAt = Date.now();
|
||||
|
||||
/**
|
||||
* Streaming-status patch. The union model replaces the whole `status`
|
||||
* object per stream event, so this guards the replacement (the pre-union
|
||||
* field patches were just as blind, but the server's empty-partial
|
||||
* keep-alives made the visible label flash in and out — the regression
|
||||
* this closes):
|
||||
* - a no-op patch (same phase + statusText) keeps the previous status
|
||||
* object instead of minting an identical one;
|
||||
* - `keepLabel` (keep-alive chunks with no new content) never downgrades
|
||||
* a labeled state (server statusText / idle thinking-finalizing) to an
|
||||
* unlabeled phase — the label persists until the status genuinely
|
||||
* changes (next label, real content, or a terminal state);
|
||||
* - `startedAt` carries over from the current streaming status so the
|
||||
* elapsed ticker never resets mid-stream.
|
||||
*/
|
||||
const patchStreamingStatus = (
|
||||
phase: AIStreamProgressPhase,
|
||||
options?: {
|
||||
statusText?: string;
|
||||
keepLabel?: boolean;
|
||||
patch?: Partial<Omit<AssistantMessage, "status">>;
|
||||
},
|
||||
) => {
|
||||
setChatMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (message.id !== assistantId || message.role !== "assistant") {
|
||||
return message;
|
||||
}
|
||||
const current =
|
||||
message.status.kind === "streaming" ? message.status : null;
|
||||
const nextStatusText = options?.statusText || undefined;
|
||||
const isNoOpStatus =
|
||||
current !== null &&
|
||||
current.phase === phase &&
|
||||
(current.statusText || undefined) === nextStatusText;
|
||||
const keepCurrentStatus =
|
||||
isNoOpStatus ||
|
||||
(options?.keepLabel === true &&
|
||||
current !== null &&
|
||||
isLabeledStreamingStatus(current));
|
||||
if (keepCurrentStatus) {
|
||||
return options?.patch
|
||||
? { ...message, ...options.patch }
|
||||
: message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
...options?.patch,
|
||||
status: {
|
||||
kind: "streaming",
|
||||
phase,
|
||||
startedAt: current?.startedAt ?? generationStartedAt,
|
||||
...(nextStatusText ? { statusText: nextStatusText } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clearIdleStatusTimeout = () => {
|
||||
if (idleStatusTimeout !== null) {
|
||||
clearTimeout(idleStatusTimeout);
|
||||
idleStatusTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleIdleStatus = () => {
|
||||
clearIdleStatusTimeout();
|
||||
idleStatusTimeout = setTimeout(() => {
|
||||
if (stopRequestedRef.current) {
|
||||
return;
|
||||
}
|
||||
patchStreamingStatus(
|
||||
hasReceivedRenderableChunk ? "finalizing" : "thinking",
|
||||
);
|
||||
}, STREAM_IDLE_STATUS_DELAY);
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
activeStreamAbortControllerRef.current = abortController;
|
||||
// A canceled predecessor skips its ownership-checked cleanup (see the
|
||||
// `finally` below), so start from a clean throttle state here.
|
||||
canvasDraft.cancelPendingRenders();
|
||||
|
||||
if (stopRequestedRef.current) {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
// Full replace on purpose: each attempt's stream starts the elapsed
|
||||
// ticker from its own `generationStartedAt` (the documented startedAt
|
||||
// reset on retry — types.ts), and wipes any seeded "Retrying..." /
|
||||
// "Regenerating..." statusText after its MIN_RETRYING_VISIBLE_MS
|
||||
// window. Every later patch preserves this baseline.
|
||||
patchAssistantMessage(assistantId, {
|
||||
status: {
|
||||
kind: "streaming",
|
||||
phase: "starting",
|
||||
startedAt: generationStartedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const { finalPayload, error, rateLimit, rateLimitRemaining } =
|
||||
await streamFetch({
|
||||
payload,
|
||||
signal: abortController.signal,
|
||||
onStreamCreated: () => {
|
||||
patchStreamingStatus("waiting");
|
||||
scheduleIdleStatus();
|
||||
},
|
||||
onStarted: (startedPayload) => {
|
||||
activeTurnId = startedPayload.turnId;
|
||||
activeMessageId = startedPayload.messageId;
|
||||
applyServerChatMetadata(startedPayload);
|
||||
patchStreamingStatus("generating", {
|
||||
patch: {
|
||||
server: {
|
||||
turnId: startedPayload.turnId,
|
||||
messageId: startedPayload.messageId,
|
||||
},
|
||||
},
|
||||
});
|
||||
scheduleIdleStatus();
|
||||
},
|
||||
onMessage: (messagePayload) => {
|
||||
patchStreamingStatus("finalizing", {
|
||||
statusText: messagePayload.message,
|
||||
});
|
||||
scheduleIdleStatus();
|
||||
},
|
||||
onChunk: (partialPayload) => {
|
||||
if (stopRequestedRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleIdleStatus();
|
||||
if (!partialPayload.skeletons.length) {
|
||||
// Keep-alive frame — no content progressed, so it must not
|
||||
// wipe a visible label (the server emits these continuously
|
||||
// while the model thinks or its internal retry backs off).
|
||||
patchStreamingStatus("generating", { keepLabel: true });
|
||||
return;
|
||||
}
|
||||
hasReceivedRenderableChunk = true;
|
||||
patchStreamingStatus("generating", {
|
||||
patch: { skeletons: partialPayload.skeletons },
|
||||
});
|
||||
// The draft is keyed by the local generation id (known
|
||||
// synchronously), so chunks render regardless of whether
|
||||
// `started` arrived yet. Always render partials as non-final:
|
||||
// the server marks its last partial `isComplete: true`, but the
|
||||
// authoritative final render (IMMEDIATELY capture + selection)
|
||||
// happens once on `done` — honoring it here would commit the
|
||||
// draft twice.
|
||||
canvasDraft.applyChunk(
|
||||
{ skeletons: partialPayload.skeletons, isComplete: false },
|
||||
assistantId,
|
||||
);
|
||||
},
|
||||
});
|
||||
onRateLimitInfo?.({ rateLimit, rateLimitRemaining });
|
||||
|
||||
if (stopRequestedRef.current || abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
patchAssistantMessage(assistantId, {
|
||||
status: {
|
||||
kind: "error",
|
||||
elapsedMs: getElapsedMs(generationStartedAt),
|
||||
error: { code: error.code, message: error.message },
|
||||
},
|
||||
});
|
||||
// On-error canvas policy (tta_rewrite_final.md §2.2): treat a failed
|
||||
// stream like user Stop — commit whatever draft rendered so the chat
|
||||
// bubble, thumbnail, and canvas agree. If nothing rendered this is a
|
||||
// no-op and the previous generation stays visible (its queued
|
||||
// replacement tag survives, so the next successful generation still
|
||||
// replaces it).
|
||||
canvasDraft.commitDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finalPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalTurnId = finalPayload.turnId ?? activeTurnId;
|
||||
const finalMessageId = finalPayload.messageId ?? activeMessageId;
|
||||
activeTurnId = finalTurnId;
|
||||
activeMessageId = finalMessageId;
|
||||
|
||||
applyServerChatMetadata({
|
||||
chatId: finalPayload.chatId,
|
||||
updatedAt: finalPayload.updatedAt,
|
||||
});
|
||||
|
||||
// A `done` carrying finishReason "length"/"content_filter" parsed, but
|
||||
// the generation was truncated/blocked — surfaced as a warning on an
|
||||
// otherwise successful status (M10); partials are kept.
|
||||
const warning =
|
||||
finalPayload.finishReason === "length" ||
|
||||
finalPayload.finishReason === "content_filter"
|
||||
? finalPayload.finishReason
|
||||
: undefined;
|
||||
|
||||
patchAssistantMessage(assistantId, {
|
||||
status: {
|
||||
kind: "done",
|
||||
elapsedMs: getElapsedMs(generationStartedAt),
|
||||
outcome: finalPayload.skeletons.length ? "generated" : "empty",
|
||||
...(warning ? { warning } : {}),
|
||||
},
|
||||
skeletons: finalPayload.skeletons,
|
||||
...(finalTurnId && finalMessageId
|
||||
? { server: { turnId: finalTurnId, messageId: finalMessageId } }
|
||||
: {}),
|
||||
// the successful attempt becomes the turn's retry target (N1)
|
||||
lastCompletedMessageId: finalMessageId ?? undefined,
|
||||
});
|
||||
canvasDraft.applyFinal(finalPayload, assistantId);
|
||||
|
||||
if (isExhaustedRateLimit(rateLimitRemaining)) {
|
||||
appendRateLimitWarningMessage();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
canvasDraft.cancelPendingRenders();
|
||||
if (stopRequestedRef.current || isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorCode = getAIChatErrorCode(error);
|
||||
|
||||
if (isHandledStreamErrorCode(errorCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (assistantMessageHasError(chatMessagesRef.current, assistantId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same on-error canvas policy as the transport-error branch above:
|
||||
// commit the rendered draft instead of wiping it. Even when the final
|
||||
// render threw mid-insert (INVALID_RESULT) after tombstoning the
|
||||
// preview frame, the commit resurrects those elements as committed.
|
||||
canvasDraft.commitDraft();
|
||||
|
||||
patchAssistantMessage(assistantId, {
|
||||
status: {
|
||||
kind: "error",
|
||||
elapsedMs: getElapsedMs(generationStartedAt),
|
||||
error: { code: errorCode, message: errorMessage },
|
||||
},
|
||||
});
|
||||
throw withAIChatErrorMeta(new Error(errorMessage), {
|
||||
handled: true,
|
||||
});
|
||||
} finally {
|
||||
clearIdleStatusTimeout();
|
||||
// Ownership-checked cleanup: when this stream was canceled and a
|
||||
// successor already took over (retry's cancel-and-replace), the ref
|
||||
// holds the successor's controller (or null) — tearing down the
|
||||
// shared throttle/stop state here would clobber the live stream.
|
||||
if (activeStreamAbortControllerRef.current === abortController) {
|
||||
canvasDraft.cancelPendingRenders();
|
||||
activeStreamAbortControllerRef.current = null;
|
||||
stopRequestedRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
applyServerChatMetadata,
|
||||
appendRateLimitWarningMessage,
|
||||
canvasDraft,
|
||||
onRateLimitInfo,
|
||||
patchAssistantMessage,
|
||||
setChatMessages,
|
||||
streamFetch,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelActiveStream = useCallback(() => {
|
||||
if (activeStreamAbortControllerRef.current) {
|
||||
activeStreamAbortControllerRef.current.abort();
|
||||
activeStreamAbortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setStopRequested = useCallback((value: boolean) => {
|
||||
stopRequestedRef.current = value;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelActiveStream();
|
||||
};
|
||||
}, [cancelActiveStream]);
|
||||
|
||||
return {
|
||||
cancelActiveStream,
|
||||
setStopRequested,
|
||||
generateResponse,
|
||||
};
|
||||
};
|
||||
@@ -1,488 +0,0 @@
|
||||
import React from "react";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { useCanvasDraft, type CanvasDraft } from "./useCanvasDraft";
|
||||
import {
|
||||
AI_GENERATED_ELEMENTS_KEY,
|
||||
INTERMEDIATE_PREVIEW_ELEMENT_KEY,
|
||||
} from "./insertAISkeletons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
const THROTTLE_DELAY = 300;
|
||||
|
||||
const createMockApp = (initialElements: ExcalidrawElement[] = []) => {
|
||||
let elements = [...initialElements];
|
||||
|
||||
const updateScene = vi.fn(
|
||||
({
|
||||
elements: nextElements,
|
||||
}: {
|
||||
elements: ExcalidrawElement[];
|
||||
captureUpdate?: unknown;
|
||||
}) => {
|
||||
elements = nextElements;
|
||||
},
|
||||
);
|
||||
const syncActionResult = vi.fn(
|
||||
({
|
||||
elements: nextElements,
|
||||
}: {
|
||||
elements: ExcalidrawElement[];
|
||||
captureUpdate?: unknown;
|
||||
}) => {
|
||||
elements = nextElements;
|
||||
},
|
||||
);
|
||||
|
||||
const app = {
|
||||
api: {
|
||||
updateScene,
|
||||
},
|
||||
state: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
zoom: { value: 1 },
|
||||
selectedElementIds: {},
|
||||
} as AppClassProperties["state"],
|
||||
scene: {
|
||||
getNonDeletedElements: () =>
|
||||
elements.filter(
|
||||
(element): element is NonDeletedExcalidrawElement =>
|
||||
!element.isDeleted,
|
||||
),
|
||||
getElementsIncludingDeleted: () => elements,
|
||||
} as unknown as AppClassProperties["scene"],
|
||||
syncActionResult,
|
||||
} as unknown as AppClassProperties;
|
||||
|
||||
return {
|
||||
app,
|
||||
getElements: () => elements,
|
||||
syncActionResult,
|
||||
updateScene,
|
||||
};
|
||||
};
|
||||
|
||||
const createCommittedGenerationElement = (
|
||||
id: string,
|
||||
generationTag: string,
|
||||
): ExcalidrawElement =>
|
||||
({
|
||||
id,
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
isDeleted: false,
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
updated: 1,
|
||||
customData: { [AI_GENERATED_ELEMENTS_KEY]: generationTag },
|
||||
} as unknown as ExcalidrawElement);
|
||||
|
||||
const renderCanvasDraft = (app: AppClassProperties) => {
|
||||
const result = { current: null as unknown as CanvasDraft };
|
||||
const Harness = () => {
|
||||
result.current = useCanvasDraft({ app });
|
||||
return null;
|
||||
};
|
||||
render(<Harness />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const rectSkeleton = (id: string): ExcalidrawElementSkeleton => ({
|
||||
type: "rectangle",
|
||||
id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
const ellipseSkeleton = (id: string): ExcalidrawElementSkeleton => ({
|
||||
type: "ellipse",
|
||||
id,
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
describe("useCanvasDraft", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("tombstones replaced preview elements and bumps same-id replacements", () => {
|
||||
vi.useFakeTimers();
|
||||
const setup = createMockApp();
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [ellipseSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
vi.advanceTimersByTime(THROTTLE_DELAY);
|
||||
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
|
||||
|
||||
const firstSyncedElements = setup.syncActionResult.mock.calls[0][0]
|
||||
.elements as ExcalidrawElement[];
|
||||
const firstPreviewElement = firstSyncedElements.find(
|
||||
(element) => element.id === "preview-id",
|
||||
)!;
|
||||
expect(
|
||||
firstPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
|
||||
).toBe(true);
|
||||
expect(firstPreviewElement.customData?.[AI_GENERATED_ELEMENTS_KEY]).toBe(
|
||||
"gen-1",
|
||||
);
|
||||
|
||||
expect(setup.updateScene).toHaveBeenCalledTimes(1);
|
||||
const removedPreviewElements = setup.updateScene.mock.calls[0][0]
|
||||
.elements as ExcalidrawElement[];
|
||||
const removedPreviewElement = removedPreviewElements.find(
|
||||
(element) => element.id === "preview-id",
|
||||
)!;
|
||||
expect(removedPreviewElement.isDeleted).toBe(true);
|
||||
expect(
|
||||
removedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
|
||||
).toBe(true);
|
||||
expect(removedPreviewElement.version).toBeGreaterThan(
|
||||
firstPreviewElement.version,
|
||||
);
|
||||
|
||||
const secondSyncedElements = setup.syncActionResult.mock.calls[1][0]
|
||||
.elements as ExcalidrawElement[];
|
||||
const secondPreviewElement = secondSyncedElements.find(
|
||||
(element) => element.id === "preview-id",
|
||||
)!;
|
||||
expect(secondPreviewElement).toMatchObject({
|
||||
id: "preview-id",
|
||||
type: "ellipse",
|
||||
isDeleted: false,
|
||||
customData: expect.objectContaining({
|
||||
[INTERMEDIATE_PREVIEW_ELEMENT_KEY]: true,
|
||||
}),
|
||||
});
|
||||
expect(secondPreviewElement.version).toBeGreaterThan(
|
||||
removedPreviewElement.version,
|
||||
);
|
||||
|
||||
// single instance per element id
|
||||
expect(
|
||||
setup.getElements().filter((element) => element.id === "preview-id"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders a chunk parked inside the throttle window via the trailing edge (M1)", () => {
|
||||
vi.useFakeTimers();
|
||||
const setup = createMockApp();
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
// leading edge rendered immediately
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [ellipseSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
// parked inside the window — no render yet
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(THROTTLE_DELAY - 1);
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
// trailing edge fires WITHOUT a subsequent chunk
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
|
||||
const trailingElements = setup.syncActionResult.mock.calls[1][0]
|
||||
.elements as ExcalidrawElement[];
|
||||
expect(
|
||||
trailingElements.find((element) => element.id === "preview-id"),
|
||||
).toMatchObject({ type: "ellipse", isDeleted: false });
|
||||
});
|
||||
|
||||
it("commits the visible preview as regular elements", () => {
|
||||
const setup = createMockApp();
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
draft.current.commitDraft();
|
||||
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
|
||||
const insertedPreviewElement = setup.syncActionResult.mock.calls[0][0]
|
||||
.elements[0] as ExcalidrawElement;
|
||||
expect(
|
||||
insertedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
|
||||
).toBe(true);
|
||||
|
||||
// pinned ordering: the updateScene-NEVER tombstone happens BEFORE the
|
||||
// syncActionResult-IMMEDIATELY commit
|
||||
expect(setup.updateScene).toHaveBeenCalledTimes(1);
|
||||
expect(setup.updateScene.mock.calls[0][0].captureUpdate).toBe(
|
||||
CaptureUpdateAction.NEVER,
|
||||
);
|
||||
const tombstonedPreviewElement = setup.updateScene.mock.calls[0][0]
|
||||
.elements[0] as ExcalidrawElement;
|
||||
expect(tombstonedPreviewElement).toMatchObject({
|
||||
id: "preview-id",
|
||||
isDeleted: true,
|
||||
});
|
||||
expect(
|
||||
tombstonedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
|
||||
).toBe(true);
|
||||
|
||||
const commitCall = setup.syncActionResult.mock.calls[1][0];
|
||||
expect(commitCall.captureUpdate).toBe(CaptureUpdateAction.IMMEDIATELY);
|
||||
expect(setup.updateScene.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
setup.syncActionResult.mock.invocationCallOrder[1],
|
||||
);
|
||||
|
||||
const committedPreviewElement = commitCall.elements[0] as ExcalidrawElement;
|
||||
expect(committedPreviewElement).toMatchObject({
|
||||
id: "preview-id",
|
||||
isDeleted: false,
|
||||
});
|
||||
// intermediate flag stripped on commit
|
||||
expect(
|
||||
committedPreviewElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
committedPreviewElement.customData?.[AI_GENERATED_ELEMENTS_KEY],
|
||||
).toBe("gen-1");
|
||||
expect(committedPreviewElement.version).toBeGreaterThan(
|
||||
tombstonedPreviewElement.version,
|
||||
);
|
||||
expect(setup.getElements()).toEqual([committedPreviewElement]);
|
||||
});
|
||||
|
||||
it("replaces the preview with the same-id final result", () => {
|
||||
const setup = createMockApp();
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("stable-preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
draft.current.applyFinal(
|
||||
{ skeletons: [ellipseSkeleton("stable-preview-id")], isComplete: true },
|
||||
"gen-1",
|
||||
);
|
||||
|
||||
expect(setup.updateScene).toHaveBeenCalledTimes(1);
|
||||
const tombstonedPreviewElement = setup.updateScene.mock.calls[0][0]
|
||||
.elements[0] as ExcalidrawElement;
|
||||
expect(tombstonedPreviewElement).toMatchObject({
|
||||
id: "stable-preview-id",
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(2);
|
||||
const finalSync = setup.syncActionResult.mock.calls[1][0];
|
||||
expect(finalSync.captureUpdate).toBe(CaptureUpdateAction.IMMEDIATELY);
|
||||
const finalElements = finalSync.elements as ExcalidrawElement[];
|
||||
expect(finalElements).toHaveLength(1);
|
||||
|
||||
const finalElement = finalElements[0];
|
||||
expect(finalElement).toMatchObject({
|
||||
id: "stable-preview-id",
|
||||
type: "ellipse",
|
||||
isDeleted: false,
|
||||
});
|
||||
expect(
|
||||
finalElement.customData?.[INTERMEDIATE_PREVIEW_ELEMENT_KEY],
|
||||
).toBeUndefined();
|
||||
expect(finalElement.version).toBeGreaterThan(
|
||||
tombstonedPreviewElement.version,
|
||||
);
|
||||
expect(setup.getElements()).toEqual([finalElement]);
|
||||
});
|
||||
|
||||
it("flushes a parked chunk before rendering the final result", () => {
|
||||
vi.useFakeTimers();
|
||||
const setup = createMockApp();
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("late-preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
draft.current.applyFinal(
|
||||
{ skeletons: [ellipseSkeleton("final-id")], isComplete: true },
|
||||
"gen-1",
|
||||
);
|
||||
|
||||
// leading chunk + flushed parked chunk + final
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(3);
|
||||
const finalElements = setup.syncActionResult.mock.calls[2][0]
|
||||
.elements as ExcalidrawElement[];
|
||||
expect(
|
||||
finalElements.filter((element) => !element.isDeleted).map((el) => el.id),
|
||||
).toEqual(["final-id"]);
|
||||
|
||||
// nothing parked anymore — no trailing render fires later
|
||||
vi.advanceTimersByTime(THROTTLE_DELAY * 2);
|
||||
expect(setup.syncActionResult).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("keeps a queued previous generation visible until the next one renders, then replaces it", () => {
|
||||
const setup = createMockApp([
|
||||
createCommittedGenerationElement("prev-el", "gen-1"),
|
||||
]);
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.queueReplacement("gen-1");
|
||||
// queueing alone must not touch the canvas
|
||||
expect(setup.getElements()[0].isDeleted).toBe(false);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
|
||||
"gen-2",
|
||||
);
|
||||
|
||||
// the previous generation was removed as a captured (IMMEDIATELY) update
|
||||
const replacementCall = setup.syncActionResult.mock.calls.find((call) =>
|
||||
(call[0].elements as ExcalidrawElement[]).some(
|
||||
(element) => element.id === "prev-el" && element.isDeleted,
|
||||
),
|
||||
);
|
||||
expect(replacementCall?.[0].captureUpdate).toBe(
|
||||
CaptureUpdateAction.IMMEDIATELY,
|
||||
);
|
||||
|
||||
const elements = setup.getElements();
|
||||
expect(
|
||||
elements.find((element) => element.id === "prev-el")?.isDeleted,
|
||||
).toBe(true);
|
||||
expect(elements.find((element) => element.id === "new-el")?.isDeleted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the queue across a stop/error that never rendered", () => {
|
||||
const setup = createMockApp([
|
||||
createCommittedGenerationElement("prev-el", "gen-1"),
|
||||
]);
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.queueReplacement("gen-1");
|
||||
// stop/error before the new generation rendered anything: the commit is a
|
||||
// no-op and the queued tag must survive
|
||||
draft.current.commitDraft();
|
||||
expect(setup.getElements()[0].isDeleted).toBe(false);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
|
||||
"gen-2",
|
||||
);
|
||||
expect(
|
||||
setup.getElements().find((element) => element.id === "prev-el")
|
||||
?.isDeleted,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces a failed attempt's committed partial on error-retry with the same generation id", () => {
|
||||
// an error-retry reuses the local generation id, so the queued tag equals
|
||||
// the retrying generation's own id — its old elements must still be
|
||||
// replaced once the new attempt renders
|
||||
const setup = createMockApp([
|
||||
createCommittedGenerationElement("old-el", "gen-1"),
|
||||
]);
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.queueReplacement("gen-1");
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
|
||||
const elements = setup.getElements();
|
||||
expect(elements.find((element) => element.id === "old-el")?.isDeleted).toBe(
|
||||
true,
|
||||
);
|
||||
expect(elements.find((element) => element.id === "new-el")?.isDeleted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the queue on reset", () => {
|
||||
const setup = createMockApp([
|
||||
createCommittedGenerationElement("prev-el", "gen-1"),
|
||||
]);
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.queueReplacement("gen-1");
|
||||
draft.current.reset();
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("new-el")], isComplete: false },
|
||||
"gen-2",
|
||||
);
|
||||
// the queued tag was dropped — the previous generation stays
|
||||
expect(
|
||||
setup.getElements().find((element) => element.id === "prev-el")
|
||||
?.isDeleted,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("clearDraft removes the active draft from the canvas without committing", () => {
|
||||
const setup = createMockApp();
|
||||
const draft = renderCanvasDraft(setup.app);
|
||||
|
||||
draft.current.applyChunk(
|
||||
{ skeletons: [rectSkeleton("preview-id")], isComplete: false },
|
||||
"gen-1",
|
||||
);
|
||||
draft.current.clearDraft();
|
||||
|
||||
const previewElement = setup
|
||||
.getElements()
|
||||
.find((element) => element.id === "preview-id");
|
||||
expect(previewElement?.isDeleted).toBe(true);
|
||||
// teardown is uncaptured — no IMMEDIATELY update was issued
|
||||
expect(
|
||||
setup.syncActionResult.mock.calls.some(
|
||||
(call) => call[0].captureUpdate === CaptureUpdateAction.IMMEDIATELY,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
setup.updateScene.mock.calls.every(
|
||||
(call) => call[0].captureUpdate === CaptureUpdateAction.NEVER,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user