Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Di be5d0bd925 refactor 2025-12-31 14:53:20 +11:00
Ryan Di 292aa1cddb fix: to support wrapping with autoResize and given dimensions 2025-12-29 17:35:00 +11:00
312 changed files with 4138 additions and 20521 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000 VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3016 VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
# The port the run the dev server # The port the run the dev server
VITE_APP_PORT=3001 VITE_APP_PORT=3000
#Debug flags #Debug flags
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version) export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE sentry-cli releases set-commits --auto $SENTRY_RELEASE
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js" sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli releases finalize $SENTRY_RELEASE sentry-cli releases finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production sentry-cli releases deploys $SENTRY_RELEASE new -e production
env: env:
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "20.x" node-version: "18.x"
- name: "Install Deps" - name: "Install Deps"
run: yarn install run: yarn install
- name: "Test Coverage" - name: "Test Coverage"
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 18.x
- name: Install and test - name: Install and test
run: | run: |
yarn install yarn install
+9 -45
View File
@@ -48,11 +48,7 @@ import {
youtubeIcon, youtubeIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element"; import { isElementLink } from "@excalidraw/element";
import { import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
bumpElementVersions,
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx"; import clsx from "clsx";
@@ -109,8 +105,8 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { import {
exportToBackend, exportToBackend,
getCollaborationLinkData, getCollaborationLinkData,
importFromBackend,
isCollaborationLink, isCollaborationLink,
loadScene,
} from "./data"; } from "./data";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
@@ -228,20 +224,9 @@ const initializeScene = async (opts: {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
let scene: Omit< let scene: RestoredDataState & {
RestoredDataState,
// we're not storing files in the scene database/localStorage, and instead
// fetch them async from a different store
"files"
> & {
scrollToContent?: boolean; scrollToContent?: boolean;
} = { } = await loadScene(null, null, localDataState);
elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
};
let roomLinkData = getCollaborationLinkData(window.location.href); let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData); const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -255,26 +240,11 @@ const initializeScene = async (opts: {
(await openConfirmModal(shareableLinkConfirmDialog)) (await openConfirmModal(shareableLinkConfirmDialog))
) { ) {
if (jsonBackendMatch) { if (jsonBackendMatch) {
const imported = await importFromBackend( scene = await loadScene(
jsonBackendMatch[1], jsonBackendMatch[1],
jsonBackendMatch[2], jsonBackendMatch[2],
localDataState,
); );
scene = {
elements: bumpElementVersions(
restoreElements(imported.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
scene.elements,
),
appState: restoreAppState(
imported.appState,
// local appState when importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
localDataState?.appState,
),
};
} }
scene.scrollToContent = true; scene.scrollToContent = true;
if (!roomLinkData) { if (!roomLinkData) {
@@ -526,10 +496,8 @@ const ExcalidrawWrapper = () => {
loadImages(data); loadImages(data);
if (data.scene) { if (data.scene) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: restoreElements(data.scene.elements, null, { ...data.scene,
repairBindings: true, ...restore(data.scene, null, null, { repairBindings: true }),
}),
appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
} }
@@ -551,11 +519,7 @@ const ExcalidrawWrapper = () => {
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: restoreElements(localDataState?.elements, null, { ...localDataState,
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
LibraryIndexedDBAdapter.load().then((data) => { LibraryIndexedDBAdapter.load().then((data) => {
-1
View File
@@ -46,7 +46,6 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library", IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations // do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+11 -28
View File
@@ -6,7 +6,7 @@ import {
reconcileElements, reconcileElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common"; import { APP_NAME, EVENT } from "@excalidraw/common";
import { import {
IDLE_THRESHOLD, IDLE_THRESHOLD,
ACTIVE_THRESHOLD, ACTIVE_THRESHOLD,
@@ -29,8 +29,6 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
import type { import type {
ReconciledExcalidrawElement, ReconciledExcalidrawElement,
RemoteExcalidrawElement, RemoteExcalidrawElement,
@@ -313,7 +311,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[], syncableElements: readonly SyncableExcalidrawElement[],
) => { ) => {
syncableElements = cloneJSON(syncableElements);
try { try {
const storedElements = await saveToFirebase( const storedElements = await saveToFirebase(
this.portal, this.portal,
@@ -582,9 +579,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.INIT: { case WS_SUBTYPES.INIT: {
if (!this.portal.socketInitialized) { if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false }); this.initializeRoom({ fetchScene: false });
const remoteElements = toBrandedType< const remoteElements = decryptedData.payload.elements;
readonly RemoteExcalidrawElement[]
>(decryptedData.payload.elements);
const reconciledElements = const reconciledElements =
this._reconcileElements(remoteElements); this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements); this.handleRemoteSceneUpdate(reconciledElements);
@@ -598,11 +593,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
} }
case WS_SUBTYPES.UPDATE: case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate( this.handleRemoteSceneUpdate(
this._reconcileElements( this._reconcileElements(decryptedData.payload.elements),
toBrandedType<readonly RemoteExcalidrawElement[]>(
decryptedData.payload.elements,
),
),
); );
break; break;
case WS_SUBTYPES.MOUSE_LOCATION: { case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -751,26 +742,18 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
private _reconcileElements = ( private _reconcileElements = (
remoteElements: readonly RemoteExcalidrawElement[], remoteElements: readonly ExcalidrawElement[],
): ReconciledExcalidrawElement[] => { ): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(
const existingElements = this.getSceneElementsIncludingDeleted();
// 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);
let reconciledElements = reconcileElements(
existingElements,
remoteElements, remoteElements,
appState, this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
); );
const reconciledElements = reconcileElements(
reconciledElements = bumpElementVersions( localElements,
reconciledElements, restoredRemoteElements as RemoteExcalidrawElement[],
existingElements, appState,
); );
// Avoid broadcasting to the rest of the collaborators the scene // Avoid broadcasting to the rest of the collaborators the scene
+52 -17
View File
@@ -4,15 +4,12 @@ import {
getTextFromElements, getTextFromElements,
MIME_TYPES, MIME_TYPES,
TTDDialog, TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common"; import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({ export const AIComponents = ({
excalidrawAPI, excalidrawAPI,
}: { }: {
@@ -102,23 +99,61 @@ export const AIComponents = ({
/> />
<TTDDialog <TTDDialog
onTextSubmit={async (props) => { onTextSubmit={async (input) => {
const { onChunk, onStreamCreated, signal, messages } = props; try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const result = await TTDStreamFetch({ const rateLimit = response.headers.has("X-Ratelimit-Limit")
url: `${ ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
import.meta.env.VITE_APP_AI_BACKEND : undefined;
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
return result; const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}} }}
persistenceAdapter={TTDIndexedDBAdapter}
/> />
</> </>
); );
+1 -2
View File
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
{isDevEnv() && ( {isDevEnv() && (
<MainMenu.Item <MainMenu.Item
icon={eyeIcon} icon={eyeIcon}
onSelect={() => { onClick={() => {
if (window.visualDebug) { if (window.visualDebug) {
delete window.visualDebug; delete window.visualDebug;
saveDebugState({ enabled: false }); saveDebugState({ enabled: false });
@@ -77,7 +77,6 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item> </MainMenu.Item>
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme allowSystemTheme
theme={props.theme} theme={props.theme}
@@ -33,15 +33,7 @@ export const AppWelcomeScreen: React.FC<{
return bit; return bit;
}); });
} else { } else {
headingContent = ( headingContent = t("welcomeScreen.app.center_heading");
<>
{t("welcomeScreen.app.center_heading")}
<br />
{t("welcomeScreen.app.center_heading_line2")}
<br />
{t("welcomeScreen.app.center_heading_line3")}
</>
);
} }
return ( return (
+1 -45
View File
@@ -27,10 +27,7 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { import type { DebugElement } from "@excalidraw/common";
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -78,44 +75,6 @@ const renderCubicBezier = (
context.restore(); context.restore();
}; };
const renderPolygon = (
context: CanvasRenderingContext2D,
zoom: number,
polygon: DebugPolygon,
color: string,
) => {
const { points, fill, close } = polygon;
if (points.length < 2) {
return;
}
context.save();
context.beginPath();
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
for (let i = 1; i < points.length; i += 1) {
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
}
if (close !== false) {
context.closePath();
}
if (fill) {
context.save();
context.globalAlpha = 0.15;
context.fillStyle = color;
context.fill();
context.restore();
}
context.strokeStyle = color;
context.stroke();
context.restore();
};
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
(data as DebugPolygon).type === "polygon";
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888"; context.strokeStyle = "#888";
context.save(); context.save();
@@ -321,9 +280,6 @@ const render = (
el.color, el.color,
); );
break; break;
case isDebugPolygon(el.data):
renderPolygon(context, appState.zoom.value, el.data, el.color);
break;
default: default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`); throw new Error(`Unknown element type ${JSON.stringify(el)}`);
} }
@@ -0,0 +1,46 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
),
);
+1 -3
View File
@@ -86,11 +86,9 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
const persistedElements = getNonDeletedElements(elements);
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(persistedElements), JSON.stringify(getNonDeletedElements(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
-51
View File
@@ -1,51 +0,0 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw"; import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common"; import { MIME_TYPES } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode"; import { decompressData } from "@excalidraw/excalidraw/data/encode";
import { import {
encryptData, encryptData,
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements); FirebaseSceneVersionCache.set(socket, storedElements);
return toBrandedType<RemoteExcalidrawElement[]>(storedElements); return storedElements;
}; };
export const loadFromFirebase = async ( export const loadFromFirebase = async (
+43 -3
View File
@@ -8,6 +8,7 @@ import {
IV_LENGTH_BYTES, IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption"; } from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
@@ -83,13 +84,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: { SCENE_INIT: {
type: WS_SUBTYPES.INIT; type: WS_SUBTYPES.INIT;
payload: { payload: {
elements: readonly OrderedExcalidrawElement[]; elements: readonly ExcalidrawElement[];
}; };
}; };
SCENE_UPDATE: { SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE; type: WS_SUBTYPES.UPDATE;
payload: { payload: {
elements: readonly OrderedExcalidrawElement[]; elements: readonly ExcalidrawElement[];
}; };
}; };
MOUSE_LOCATION: { MOUSE_LOCATION: {
@@ -199,7 +200,7 @@ const legacy_decodeFromBackend = async ({
}; };
}; };
export const importFromBackend = async ( const importFromBackend = async (
id: string, id: string,
decryptionKey: string, decryptionKey: string,
): Promise<ImportedDataState> => { ): Promise<ImportedDataState> => {
@@ -241,6 +242,45 @@ export const importFromBackend = async (
} }
}; };
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply local state even if importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
// Non-optional so we don't forget to pass it even if `undefined`.
localDataState: ImportedDataState | undefined | null,
) => {
let data;
if (id != null && privateKey != null) {
// the private key is used to decrypt the content from the server, take
// extra care not to leak it
data = restore(
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}
return {
elements: data.elements,
appState: data.appState,
// note: this will always be empty because we're not storing files
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
};
};
type ExportToBackendResult = type ExportToBackendResult =
| { url: null; errorMessage: string } | { url: null; errorMessage: string }
| { url: string; errorMessage: null }; | { url: string; errorMessage: null };
+24 -51
View File
@@ -10,6 +10,8 @@ const lessPrecise = (num: number, precision = 5) =>
const getAvgFrameTime = (times: number[]) => const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length); lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug { export class Debug {
public static DEBUG_LOG_TIMES = true; public static DEBUG_LOG_TIMES = true;
@@ -22,35 +24,34 @@ export class Debug {
private static LAST_DEBUG_LOG_CALL = 0; private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null; private static DEBUG_LOG_INTERVAL_ID: null | number = null;
private static LAST_FRAME_TIMESTAMP = 0;
private static FRAME_COUNT = 0;
private static ANIMATION_FRAME_ID: null | number = null;
private static scheduleAnimationFrame = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
Debug.LAST_FRAME_TIMESTAMP = timestamp;
Debug.FRAME_COUNT++;
}
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
Debug.scheduleAnimationFrame();
}
});
}
};
private static setupInterval = () => { private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) { if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime"); console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000); Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
Debug.scheduleAnimationFrame();
} }
Debug.LAST_DEBUG_LOG_CALL = Date.now(); Debug.LAST_DEBUG_LOG_CALL = Date.now();
}; };
private static debugLogger = () => { private static debugLogger = () => {
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
Debug.DEBUG_LOG_INTERVAL_ID = null;
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
if (avg != null) {
console.info(
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
"color: blue",
);
}
}
console.info("%c(stopping perf recording)", "color: red");
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
return;
}
if (Debug.DEBUG_LOG_TIMES) { if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) { for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) { if (times.length) {
@@ -64,18 +65,8 @@ export class Debug {
} }
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) { for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) { if (times.length) {
// const avgFrameTime = getAvgFrameTime(times); const avgFrameTime = getAvgFrameTime(times);
const totalTime = times.reduce((a, b) => a + b); console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
console.info(
name,
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${
Debug.FRAME_COUNT
} frames (${lessPrecise(
(avgFrameTime / 16.67) * 100,
1,
)}% of frame budget)`,
);
Debug.TIMES_AVG[name] = { Debug.TIMES_AVG[name] = {
t, t,
times: [], times: [],
@@ -85,24 +76,6 @@ export class Debug {
} }
} }
} }
Debug.FRAME_COUNT = 0;
// Check for stop condition after logging
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
console.info("%c(stopping perf recording)", "color: red");
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
Debug.ANIMATION_FRAME_ID = null;
Debug.FRAME_COUNT = 0;
Debug.LAST_FRAME_TIMESTAMP = 0;
Debug.DEBUG_LOG_INTERVAL_ID = null;
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
}
}; };
public static logTime = (time?: number, name = "default") => { public static logTime = (time?: number, name = "default") => {
@@ -136,7 +109,7 @@ export class Debug {
return (...args: T) => { return (...args: T) => {
const t0 = performance.now(); const t0 = performance.now();
const ret = fn(...args); const ret = fn(...args);
Debug[type](performance.now() - t0, name); Debug.logTime(performance.now() - t0, name);
return ret; return ret;
}; };
}; };
-1
View File
@@ -36,7 +36,6 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"socket.io-client": "4.7.2", "socket.io-client": "4.7.2",
"uqr": "0.1.2",
"vite-plugin-html": "3.2.2" "vite-plugin-html": "3.2.2"
}, },
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
-56
View File
@@ -1,56 +0,0 @@
import { useEffect, useState } from "react";
import Spinner from "@excalidraw/excalidraw/components/Spinner";
interface QRCodeProps {
value: string;
}
export const QRCode = ({ value }: QRCodeProps) => {
const [svgData, setSvgData] = useState<string | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
import("./qrcode.chunk")
.then(({ generateQRCodeSVG }) => {
if (mounted) {
try {
setSvgData(generateQRCodeSVG(value));
} catch {
setError(true);
}
}
})
.catch(() => {
if (mounted) {
setError(true);
}
});
return () => {
mounted = false;
};
}, [value]);
if (error) {
return null;
}
if (!svgData) {
return (
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
<Spinner />
</div>
);
}
return (
<div
className="ShareDialog__active__qrcode"
role="img"
aria-label="QR code for collaboration link"
dangerouslySetInnerHTML={{ __html: svgData }}
/>
);
};
-25
View File
@@ -140,31 +140,6 @@
gap: 0.75rem; gap: 0.75rem;
} }
&__qrcode {
display: flex;
justify-content: center;
align-items: center;
align-self: center;
padding: 1rem;
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e0e0e0;
$size: 150px;
width: $size;
height: $size;
& svg {
width: $size;
height: $size;
}
&--loading {
background: var(--island-bg-color);
border: 1px solid var(--dialog-border-color);
}
}
&__description { &__description {
border-top: 1px solid var(--color-gray-20); border-top: 1px solid var(--color-gray-20);
-2
View File
@@ -22,7 +22,6 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab"; import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss"; import "./ShareDialog.scss";
import { QRCode } from "./QRCode";
import type { CollabAPI } from "../collab/Collab"; import type { CollabAPI } from "../collab/Collab";
@@ -143,7 +142,6 @@ const ActiveRoomDialog = ({
}} }}
/> />
</div> </div>
<QRCode value={activeRoomLink} />
<div className="ShareDialog__active__description"> <div className="ShareDialog__active__description">
<p> <p>
<span <span
-5
View File
@@ -1,5 +0,0 @@
import { renderSVG } from "uqr";
export const generateQRCodeSVG = (text: string): string => {
return renderSVG(text);
};
@@ -50,11 +50,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div <div
class="welcome-screen-center__heading welcome-screen-decor excalifont" class="welcome-screen-center__heading welcome-screen-decor excalifont"
> >
Your drawings are saved in your browser's storage. All your data is saved locally in your browser.
<br />
Browser storage can be cleared unexpectedly.
<br />
Save your work to a file regularly to avoid losing it.
</div> </div>
<div <div
class="welcome-screen-menu" class="welcome-screen-menu"
+14 -125
View File
@@ -69,114 +69,6 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should preserve future element fields across collab reconciliation", async () => {
await render(<ExcalidrawApp />);
const frame = API.createElement({
type: "frame",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ff0000",
});
const frameWithFutureFields = {
...frame,
schemaState: {
tracks: {
...frame.schemaState.tracks,
"host.myapp.frame": 1,
},
},
futureField: "keep-me",
} as typeof frame & {
schemaState: { tracks: Record<string, number> };
futureField: string;
};
API.updateScene({
elements: [frameWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toBe("keep-me");
expect((h.elements[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
expect(h.elements[0].backgroundColor).toBe("#ff0000");
});
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
x: 120,
y: 80,
});
const reconciled = (window.collab as any)._reconcileElements([
remoteMovedFrame,
]);
expect(reconciled[0]).toEqual(
expect.objectContaining({
x: 120,
y: 80,
backgroundColor: "#ff0000",
}),
);
expect((reconciled[0] as any).futureField).toBe("keep-me");
expect((reconciled[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
});
it("should preserve future element fields on local edits before broadcast", async () => {
await render(<ExcalidrawApp />);
const rect = API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rectWithFutureFields = {
...rect,
schemaState: {
tracks: {
...rect.schemaState.tracks,
"host.myapp.rect": 1,
},
},
futureField: { value: "keep-me" },
} as typeof rect & {
schemaState: { tracks: Record<string, number> };
futureField: { value: string };
};
API.updateScene({
elements: [rectWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
API.updateScene({
elements: [locallyEdited],
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
expect((h.elements[0] as any).schemaState).toEqual(
rectWithFutureFields.schemaState,
);
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
});
});
it("should emit two ephemeral increments even though updates get batched", async () => { it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = []; const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = []; const ephemeralIncrements: EphemeralIncrement[] = [];
@@ -191,18 +83,14 @@ describe("collaboration", () => {
} }
}); });
// Ensure this test starts from a deterministic scene regardless of previous // eslint-disable-next-line dot-notation
// test state restored from persistence. expect(h.store["scheduledMicroActions"].length).toBe(0);
API.updateScene({ expect(durableIncrements.length).toBe(0);
elements: [], expect(ephemeralIncrements.length).toBe(0);
captureUpdate: CaptureUpdateAction.NEVER,
});
const durableBaseline = durableIncrements.length;
const ephemeralBaseline = ephemeralIncrements.length;
const rectProps = { const rectProps = {
type: "rectangle", type: "rectangle",
id: "A",
height: 200, height: 200,
width: 100, width: 100,
x: 0, x: 0,
@@ -217,7 +105,8 @@ describe("collaboration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(durableIncrements.length).toBe(durableBaseline + 1); // expect(commitSpy).toHaveBeenCalledTimes(1);
expect(durableIncrements.length).toBe(1);
}); });
// simulate two batched remote updates // simulate two batched remote updates
@@ -241,13 +130,13 @@ describe("collaboration", () => {
// altough the updates get batched, // altough the updates get batched,
// we expect two ephemeral increments for each update, // we expect two ephemeral increments for each update,
// and each such update should have the expected change // and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(ephemeralBaseline + 2); expect(ephemeralIncrements.length).toBe(2);
expect( expect(ephemeralIncrements[0].change.elements.A).toEqual(
ephemeralIncrements[ephemeralBaseline].change.elements[rect.id], expect.objectContaining({ x: 100 }),
).toEqual(expect.objectContaining({ x: 100 })); );
expect( expect(ephemeralIncrements[1].change.elements.A).toEqual(
ephemeralIncrements[ephemeralBaseline + 1].change.elements[rect.id], expect.objectContaining({ x: 200 }),
).toEqual(expect.objectContaining({ x: 200 })); );
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0); expect(h.store["scheduledMicroActions"].length).toBe(0);
}); });
-5
View File
@@ -102,10 +102,6 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
}, },
}, },
}, },
@@ -200,7 +196,6 @@ export default defineConfig(({ mode }) => {
}, },
}, },
], ],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
}, },
manifest: { manifest: {
short_name: "Excalidraw", short_name: "Excalidraw",
-6
View File
@@ -55,11 +55,5 @@
"scripts": { "scripts": {
"gen:types": "rimraf types && tsc", "gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"tinycolor2": "1.6.0"
},
"devDependencies": {
"@types/tinycolor2": "1.4.6"
} }
} }
@@ -1,185 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
{
"black": "#1e1e1e",
"blue": [
"#e7f5ff",
"#a5d8ff",
"#4dabf7",
"#228be6",
"#1971c2",
],
"bronze": [
"#f8f1ee",
"#eaddd7",
"#d2bab0",
"#a18072",
"#846358",
],
"cyan": [
"#e3fafc",
"#99e9f2",
"#3bc9db",
"#15aabf",
"#0c8599",
],
"grape": [
"#f8f0fc",
"#eebefa",
"#da77f2",
"#be4bdb",
"#9c36b5",
],
"gray": [
"#f8f9fa",
"#e9ecef",
"#ced4da",
"#868e96",
"#343a40",
],
"green": [
"#ebfbee",
"#b2f2bb",
"#69db7c",
"#40c057",
"#2f9e44",
],
"orange": [
"#fff4e6",
"#ffd8a8",
"#ffa94d",
"#fd7e14",
"#e8590c",
],
"pink": [
"#fff0f6",
"#fcc2d7",
"#f783ac",
"#e64980",
"#c2255c",
],
"red": [
"#fff5f5",
"#ffc9c9",
"#ff8787",
"#fa5252",
"#e03131",
],
"teal": [
"#e6fcf5",
"#96f2d7",
"#38d9a9",
"#12b886",
"#099268",
],
"transparent": "transparent",
"violet": [
"#f3f0ff",
"#d0bfff",
"#9775fa",
"#7950f2",
"#6741d9",
],
"white": "#ffffff",
"yellow": [
"#fff9db",
"#ffec99",
"#ffd43b",
"#fab005",
"#f08c00",
],
}
`;
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{
"black": "#d3d3d3",
"blue": [
"#121e26",
"#154162",
"#2273b4",
"#3791e0",
"#56a2e8",
],
"bronze": [
"#221c1a",
"#362b26",
"#5a463d",
"#917569",
"#a98d84",
],
"cyan": [
"#0a1e20",
"#004149",
"#007281",
"#0f8fa1",
"#3da5b6",
],
"grape": [
"#211a25",
"#5b3165",
"#a954be",
"#d471ed",
"#e28af8",
],
"gray": [
"#161718",
"#202325",
"#33383d",
"#6e757c",
"#b7bcc1",
],
"green": [
"#0f1d12",
"#043b0c",
"#056715",
"#16842a",
"#39994b",
],
"orange": [
"#22190d",
"#4c2b01",
"#924800",
"#cd6005",
"#f17634",
],
"pink": [
"#26191e",
"#602e40",
"#b04d70",
"#f56e9d",
"#ff8dbc",
],
"red": [
"#1f1717",
"#5a2c2c",
"#b44d4d",
"#fa6969",
"#ff8383",
],
"teal": [
"#0a1d17",
"#00422b",
"#00744b",
"#039267",
"#32a783",
],
"transparent": "#ededed00",
"violet": [
"#1f1c29",
"#4a3b72",
"#8a6cdf",
"#a885ff",
"#b595ff",
],
"white": "#121212",
"yellow": [
"#1e1900",
"#362600",
"#5f3a00",
"#905000",
"#b86200",
],
}
`;
-286
View File
@@ -1,286 +0,0 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
rgbToHex,
} from "@excalidraw/common";
describe("COLOR_PALETTE", () => {
it("color palette doesn't regress", () => {
expect(COLOR_PALETTE).toMatchSnapshot();
});
});
describe("applyDarkModeFilter", () => {
describe("basic transformations", () => {
it("transforms black to near-white", () => {
const result = applyDarkModeFilter("#000000");
// Black inverted 93% + hue rotate should be near white/light gray
expect(result).toBe("#ededed");
});
it("transforms white to near-black", () => {
const result = applyDarkModeFilter("#ffffff");
// White inverted 93% should be near black/dark gray
expect(result).toBe("#121212");
});
it("transforms pure red", () => {
const result = applyDarkModeFilter("#ff0000");
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
expect(result).toBe("#ff9090");
});
it("transforms pure green", () => {
const result = applyDarkModeFilter("#00ff00");
// Invert 93% + hue rotate 180deg
expect(result).toBe("#008f00");
});
it("transforms pure blue", () => {
const result = applyDarkModeFilter("#0000ff");
// Invert 93% + hue rotate 180deg produces a light purple
expect(result).toBe("#cdcdff");
});
});
describe("color formats", () => {
it("handles hex with hash", () => {
const result = applyDarkModeFilter("#ff0000");
// Fully opaque colors return 6-char hex
expect(result).toMatch(/^#[0-9a-f]{6}$/);
});
it("handles named colors", () => {
const result = applyDarkModeFilter("red");
// "red" = #ff0000, fully opaque
expect(result).toBe("#ff9090");
});
it("handles rgb format", () => {
const result = applyDarkModeFilter("rgb(255, 0, 0)");
expect(result).toBe("#ff9090");
});
it("handles rgba format and preserves alpha", () => {
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
expect(result).toMatch(/^#[0-9a-f]{8}$/);
// Alpha 0.5 = 128 in hex = 80
expect(result).toBe("#ff909080");
});
it("handles transparent", () => {
const result = applyDarkModeFilter("transparent");
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
expect(result).toBe("#ededed00");
});
it("handles shorthand hex", () => {
const result = applyDarkModeFilter("#f00");
expect(result).toBe("#ff9090");
});
});
describe("alpha preservation", () => {
it("omits alpha for full opacity", () => {
const result = applyDarkModeFilter("#ff0000ff");
// Full opacity returns 6-char hex (no alpha suffix)
expect(result).toBe("#ff9090");
});
it("preserves 50% opacity", () => {
const result = applyDarkModeFilter("#ff000080");
expect(result.slice(-2)).toBe("80");
});
it("preserves 0% opacity", () => {
const result = applyDarkModeFilter("#ff000000");
expect(result.slice(-2)).toBe("00");
});
});
describe("COLOR_PALETTE regression tests", () => {
it("transforms black from palette", () => {
// COLOR_PALETTE.black is #1e1e1e (not pure black)
const result = applyDarkModeFilter(COLOR_PALETTE.black);
expect(result).toBe("#d3d3d3");
});
it("transforms white from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.white);
expect(result).toBe("#121212");
});
it("transforms transparent from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
expect(result).toBe("#ededed00");
});
// Test each color family from the palette (all opaque, so 6-char hex)
describe("red shades", () => {
const redShades = COLOR_PALETTE.red;
it.each(redShades.map((color, i) => [color, i]))(
"transforms red shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("blue shades", () => {
const blueShades = COLOR_PALETTE.blue;
it.each(blueShades.map((color, i) => [color, i]))(
"transforms blue shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("green shades", () => {
const greenShades = COLOR_PALETTE.green;
it.each(greenShades.map((color, i) => [color, i]))(
"transforms green shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("gray shades", () => {
const grayShades = COLOR_PALETTE.gray;
it.each(grayShades.map((color, i) => [color, i]))(
"transforms gray shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("bronze shades", () => {
const bronzeShades = COLOR_PALETTE.bronze;
it.each(bronzeShades.map((color, i) => [color, i]))(
"transforms bronze shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
// Snapshot test for full palette to catch any regressions
it("matches snapshot for all palette colors", () => {
const transformedPalette: Record<string, string | string[]> = {};
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
transformedPalette.transparent = applyDarkModeFilter(
COLOR_PALETTE.transparent,
);
// Transform color arrays
for (const colorName of [
"gray",
"red",
"pink",
"grape",
"violet",
"blue",
"cyan",
"teal",
"green",
"yellow",
"orange",
"bronze",
] as const) {
const shades = COLOR_PALETTE[colorName];
transformedPalette[colorName] = shades.map((shade) =>
applyDarkModeFilter(shade),
);
}
expect(transformedPalette).toMatchSnapshot();
});
});
describe("caching", () => {
it("returns same result for same input (cached)", () => {
const result1 = applyDarkModeFilter("#ff0000");
const result2 = applyDarkModeFilter("#ff0000");
expect(result1).toBe(result2);
});
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
expect(rgbToHex(0, 0, 0)).toBe("#000000");
});
it("converts white (255,255,255)", () => {
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
});
it("converts red (255,0,0)", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
});
it("converts green (0,255,0)", () => {
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
});
it("converts blue (0,0,255)", () => {
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
});
it("converts arbitrary color", () => {
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
});
});
describe("leading zeros preservation", () => {
it("preserves leading zeros for low values", () => {
expect(rgbToHex(0, 0, 1)).toBe("#000001");
expect(rgbToHex(0, 1, 0)).toBe("#000100");
expect(rgbToHex(1, 0, 0)).toBe("#010000");
});
it("preserves zeros for single-digit hex values", () => {
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
});
});
describe("alpha handling", () => {
it("omits alpha when undefined", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
});
it("omits alpha when fully opaque (1)", () => {
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
});
it("includes alpha for semi-transparent (0.5)", () => {
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
});
it("includes alpha for fully transparent (0)", () => {
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
});
it("includes alpha for near-opaque (0.99)", () => {
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
});
it("pads alpha with leading zero when needed", () => {
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
});
});
});
+43 -226
View File
@@ -1,117 +1,8 @@
import tinycolor from "tinycolor2"; import oc from "open-color";
import { clamp } from "@excalidraw/math"; import type { Merge } from "./utility-types";
import { degreesToRadians } from "@excalidraw/math";
import type { Degrees } from "@excalidraw/math"; export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
// Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
function cssHueRotate(
red: number,
green: number,
blue: number,
degrees: Degrees,
): { r: number; g: number; b: number } {
// normalize
const r = red / 255;
const g = green / 255;
const b = blue / 255;
// Convert degrees to radians
const a = degreesToRadians(degrees);
const c = Math.cos(a);
const s = Math.sin(a);
// rotation matrix
const matrix = [
0.213 + c * 0.787 - s * 0.213,
0.715 - c * 0.715 - s * 0.715,
0.072 - c * 0.072 + s * 0.928,
0.213 - c * 0.213 + s * 0.143,
0.715 + c * 0.285 + s * 0.14,
0.072 - c * 0.072 - s * 0.283,
0.213 - c * 0.213 - s * 0.787,
0.715 - c * 0.715 + s * 0.715,
0.072 + c * 0.928 + s * 0.072,
];
// transform
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
// clamp the values to [0, 1] range and convert back to [0, 255]
return {
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
};
}
const cssInvert = (
r: number,
g: number,
b: number,
percent: number,
): { r: number; g: number; b: number } => {
const p = clamp(percent, 0, 100) / 100;
// Function to invert a single color component
const invertComponent = (color: number): number => {
// Apply the invert formula
const inverted = color * (1 - p) + (255 - color) * p;
// Round to the nearest integer and clamp to [0, 255]
return Math.round(clamp(inverted, 0, 255));
};
// Calculate the inverted RGB components
const invertedR = invertComponent(r);
const invertedG = invertComponent(g);
const invertedB = invertComponent(b);
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string): string => {
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
}
const tc = tinycolor(color);
const alpha = tc.getAlpha();
// 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 rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as Degrees,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
if (DARK_MODE_COLORS_CACHE) {
DARK_MODE_COLORS_CACHE.set(color, result);
}
return result;
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
@@ -126,7 +17,15 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>; }, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
}; };
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string]; export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors // used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string }; export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
@@ -139,30 +38,38 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4; export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1; export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
};
export const COLOR_PALETTE = { export const COLOR_PALETTE = {
transparent: "transparent", transparent: "transparent",
black: "#1e1e1e", black: "#1e1e1e",
white: "#ffffff", white: "#ffffff",
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js // open-colors
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800) gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"], red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"], pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"], grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"], violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"], blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"], cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"], teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"], green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"], yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"], orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"], // radix bronze shades 3,5,7,9,11
// radix bronze shades [3,5,7,9,11]
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"], bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
} as const; } as ColorPalette;
export type ColorPalette = typeof COLOR_PALETTE;
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan", "cyan",
@@ -177,6 +84,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red", "red",
]); ]);
// -----------------------------------------------------------------------------
// quick picks defaults // quick picks defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -211,6 +119,7 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6", "#fdf8f6",
] as ColorTuple; ] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults // palette defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -236,7 +145,8 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
} as const; } as const;
// color palette helpers // -----------------------------------------------------------------------------
// helpers
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
@@ -257,100 +167,7 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
COLOR_PALETTE.red[index], COLOR_PALETTE.red[index],
] as const; ] as const;
// ----------------------------------------------------------------------------- export const rgbToHex = (r: number, g: number, b: number) =>
// other helpers `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
// -----------------------------------------------------------------------------
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
// then slice(1) removes the leading "1" to get exactly 6 hex digits
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)}`;
if (a !== undefined && a < 1) {
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
// e.g. 0.5 -> 128 -> "80"
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, "0");
return `${hex6}${alphaHex}`;
}
return hex6;
};
/**
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
* null if not valid color
*/
export const colorToHex = (color: string): string | null => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return null;
}
const { r, g, b, a } = tc.toRgb();
return rgbToHex(r, g, b, a);
};
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
// -----------------------------------------------------------------------------
// normalization
// -----------------------------------------------------------------------------
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
+3 -4
View File
@@ -106,7 +106,6 @@ export const CLASSES = {
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name", FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
}; };
export const FONT_SIZES = { export const FONT_SIZES = {
@@ -191,8 +190,6 @@ export const THEME = {
DARK: "dark", DARK: "dark",
} as const; } as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = { export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"], strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"], strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
@@ -252,7 +249,6 @@ export const STRING_MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data // LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids // list of excalidraw library item ids
@@ -309,6 +305,9 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds // Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000; export const ACTIVE_THRESHOLD = 3_000;
// duplicates --theme-filter, should be removed soon
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const URL_QUERY_KEYS = { export const URL_QUERY_KEYS = {
addLibrary: "addLibrary", addLibrary: "addLibrary",
} as const; } as const;
+3 -2
View File
@@ -16,6 +16,7 @@ export type EditorInterface = Readonly<{
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode"; const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints // breakpoints
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
@@ -23,9 +24,9 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets // tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1180; // ipad air export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop (NOTE: not used for form factor detection) // desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440; export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
+1
View File
@@ -11,4 +11,5 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface"; export * from "./editorInterface";
+28 -47
View File
@@ -10,6 +10,7 @@ import type {
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import { import {
DEFAULT_VERSION, DEFAULT_VERSION,
ENV, ENV,
@@ -547,6 +548,16 @@ export const mapFind = <T, K>(
return undefined; return undefined;
}; };
export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
return (
isRGBTransparent ||
isRRGGBBTransparent ||
color === COLOR_PALETTE.transparent
);
};
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void
@@ -1146,69 +1157,39 @@ export const normalizeEOL = (str: string) => {
}; };
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export type HasBrand<T> = { type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never; [K in keyof T]: K extends `~brand${infer _}` ? true : never;
}[keyof T]; }[keyof T];
type RemoveAllBrands<T> = HasBrand<T> extends true type RemoveAllBrands<T> = HasBrand<T> extends true
? { ? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T as K extends `~brand~${infer _}` | "_brand" [K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
? never
: K]: T[K];
} }
: T; : never;
// For accepting values - uses loose matching for branded types // adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
// Preserves readonly modifier: mutable array requires mutable input // currently does not cover all types (e.g. tuples, promises...)
type UnbrandForValue<T> = T extends Map<infer E, infer F> type Unbrand<T> = T extends Map<infer E, infer F>
? Map<UnbrandForValue<E>, UnbrandForValue<F>> ? Map<E, F>
: T extends Set<infer E> : T extends Set<infer E>
? Set<UnbrandForValue<E>> ? Set<E>
: T extends readonly any[] : T extends Array<infer E>
? T extends any[] ? Array<E>
? unknown[] // mutable array - require mutable input
: readonly unknown[] // readonly array - accept readonly input
: RemoveAllBrands<T>; : RemoveAllBrands<T>;
// For return types - preserves array element unbranding
export type Unbrand<T> = T extends Map<infer E, infer F>
? Map<Unbrand<E>, Unbrand<F>>
: T extends Set<infer E>
? Set<Unbrand<E>>
: T extends readonly (infer E)[]
? Array<Unbrand<E>>
: RemoveAllBrands<T>;
export type CombineBrands<BrandedType, CurrentType> =
BrandedType extends readonly (infer BE)[]
? CurrentType extends readonly (infer CE)[]
? Array<CE & BE>
: CurrentType & BrandedType
: CurrentType & BrandedType;
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
? T[]
: HasBrand<T> extends true
? CombineBrands<T, Required>[]
: Required[];
/** /**
* Makes type into a branded type, ensuring that value is assignable to * Makes type into a branded type, ensuring that value is assignable to
* the base unbranded type. Optionally you can explicitly supply current value * the base ubranded type. Optionally you can explicitly supply current value
* type to combine both (useful for composite branded types. Make sure you * type to combine both (useful for composite branded types. Make sure you
* compose branded types which are not composite themselves.) * compose branded types which are not composite themselves.)
*/ */
export function toBrandedType<BrandedType>( export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
value: UnbrandForValue<BrandedType>, value: Unbrand<BrandedType>,
): BrandedType; ) => {
export function toBrandedType<BrandedType, CurrentType>( return value as CurrentType & BrandedType;
value: CurrentType, };
): CombineBrands<BrandedType, CurrentType>;
export function toBrandedType(value: unknown) {
return value;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -1,24 +1,16 @@
import { import {
isLineSegment, isLineSegment,
lineSegment, lineSegment,
pointDistanceSq,
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
getElementBounds,
intersectElementWithLineSegment,
isFreeDrawElement,
isLinearElement,
isPathALoop,
} from "@excalidraw/element";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils"; import type { LineSegment } from "@excalidraw/utils";
import { type Bounds, isBounds } from "./bounds";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
interface Window { interface Window {
@@ -31,69 +23,10 @@ declare global {
export type DebugElement = { export type DebugElement = {
color: string; color: string;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon; data: LineSegment<GlobalPoint> | Curve<GlobalPoint>;
permanent: boolean; permanent: boolean;
}; };
export type DebugPolygon = {
type: "polygon";
points: GlobalPoint[];
fill?: boolean;
close?: boolean;
};
export const debugDrawHitVolume = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
options?: {
rays?: number;
color?: string;
fill?: boolean;
},
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
return;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const rays = options?.rays ?? 100;
const radius = Math.max(x2 - x1, y2 - y1) * 2;
const points: GlobalPoint[] = [];
for (let i = 0; i < rays; i += 1) {
const angle = (i / rays) * Math.PI * 2;
const end = pointFrom<GlobalPoint>(
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius,
);
const hits = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(center, end),
);
if (hits.length === 0) {
continue;
}
hits.sort(pointDistanceSq);
points.push(hits[0]);
}
if (points.length >= 3) {
debugDrawPolygon(points, {
color: options?.color ?? "orange",
fill: options?.fill ?? true,
});
} else {
console.warn(
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
);
}
};
export const debugDrawCubicBezier = ( export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>, c: Curve<GlobalPoint>,
opts?: { opts?: {
@@ -128,31 +61,6 @@ export const debugDrawLine = (
); );
}; };
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
fill?: boolean;
close?: boolean;
},
) => {
if (points.length < 2) {
return;
}
addToCurrentFrame({
color: opts?.color ?? "orange",
permanent: !!opts?.permanent,
data: {
type: "polygon",
points,
fill: opts?.fill,
close: opts?.close,
},
});
};
export const debugDrawPoint = ( export const debugDrawPoint = (
p: GlobalPoint, p: GlobalPoint,
opts?: { opts?: {
@@ -193,7 +101,7 @@ export const debugDrawBounds = (
permanent?: boolean; permanent?: boolean;
}, },
) => { ) => {
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) => (isBounds(box) ? [box] : box).forEach((bbox) =>
debugDrawLine( debugDrawLine(
[ [
lineSegment( lineSegment(
-6
View File
@@ -17,12 +17,6 @@
"development": "./dist/dev/index.js", "development": "./dist/dev/index.js",
"production": "./dist/prod/index.js", "production": "./dist/prod/index.js",
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
},
"./visualdebug": {
"types": "./dist/types/element/src/visualdebug.d.ts",
"development": "./dist/dev/visualdebug.js",
"production": "./dist/prod/visualdebug.js",
"default": "./dist/prod/visualdebug.js"
} }
}, },
"files": [ "files": [
@@ -27,9 +27,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#66a80f", "strokeColor": "#66a80f",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -67,9 +64,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#9c36b5", "strokeColor": "#9c36b5",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -122,9 +116,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -186,9 +177,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -235,9 +223,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -281,9 +266,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -330,9 +312,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "Whats up ?", "originalText": "Whats up ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -393,9 +372,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -443,9 +419,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -506,9 +479,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -556,9 +526,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -599,9 +566,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -639,9 +603,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -699,9 +660,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -749,9 +707,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -798,9 +753,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -847,9 +799,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "WHATS UP ?", "originalText": "WHATS UP ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -885,9 +834,6 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -933,9 +879,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -983,9 +926,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": "dot", "startArrowhead": "dot",
"startBinding": null, "startBinding": null,
@@ -1033,9 +973,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1083,9 +1020,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1120,9 +1054,6 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1155,9 +1086,6 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1190,9 +1118,6 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1225,9 +1150,6 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1260,9 +1182,6 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "dotted", "strokeStyle": "dotted",
@@ -1295,9 +1214,6 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1971c2", "strokeColor": "#1971c2",
"strokeStyle": "dashed", "strokeStyle": "dashed",
@@ -1336,9 +1252,6 @@ exports[`Test Transform > should transform text element 1`] = `
"originalText": "HELLO WORLD!", "originalText": "HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1380,9 +1293,6 @@ exports[`Test Transform > should transform text element 2`] = `
"originalText": "STYLED HELLO WORLD!", "originalText": "STYLED HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#5f3dc4", "strokeColor": "#5f3dc4",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1429,9 +1339,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1471,9 +1378,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1517,9 +1421,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1567,9 +1468,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1629,9 +1527,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1696,9 +1591,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1748,9 +1640,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "B", "originalText": "B",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1794,9 +1683,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "A", "originalText": "A",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1840,9 +1726,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Alice", "originalText": "Alice",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1886,9 +1769,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Bob", "originalText": "Bob",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1930,9 +1810,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "How are you?", "originalText": "How are you?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1974,9 +1851,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Friendship", "originalText": "Friendship",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2030,9 +1904,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2085,9 +1956,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2140,9 +2008,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2195,9 +2060,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2238,9 +2100,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "LABELED ARROW", "originalText": "LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2282,9 +2141,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "STYLED LABELED ARROW", "originalText": "STYLED LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2326,9 +2182,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1098ad", "strokeColor": "#1098ad",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2371,9 +2224,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2415,9 +2265,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2455,9 +2302,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2495,9 +2339,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2535,9 +2376,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2575,9 +2413,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2615,9 +2450,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#f08c00", "strokeColor": "#f08c00",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2656,9 +2488,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "RECTANGLE TEXT CONTAINER", "originalText": "RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2700,9 +2529,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "ELLIPSE TEXT CONTAINER", "originalText": "ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2746,9 +2572,6 @@ exports[`Test Transform > should transform to text containers when label provide
TEXT CONTAINER", TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2792,9 +2615,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED DIAMOND TEXT CONTAINER", "originalText": "STYLED DIAMOND TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2837,9 +2657,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER", "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2883,9 +2700,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED ELLIPSE TEXT CONTAINER", "originalText": "STYLED ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
-558
View File
@@ -1,558 +0,0 @@
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { invariant } from "@excalidraw/common";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import {
bindBindingElement,
calculateFixedPointForNonElbowArrowBinding,
FOCUS_POINT_SIZE,
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
import {
isBindableElement,
isBindingElement,
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
import type { Scene } from "../Scene";
export const isFocusPointVisible = (
focusPoint: GlobalPoint,
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
appState: {
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
startOrEnd: "start" | "end",
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
// always have their focus point at the arrow point itself
if (
isElbowArrow(arrow) ||
!isBindingEnabled(appState) ||
arrow.points.length !== 2
) {
return false;
}
// Avoid showing the focus point indicator if the focus point is essentially
// on top of the arrow point it belongs to itself, if not ignoring specifically
if (!ignoreOverlap) {
const associatedPointIdx =
arrow.startBinding?.elementId === bindableElement.id
? 0
: arrow.points.length - 1;
const associatedArrowPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
associatedPointIdx,
elementsMap,
);
if (
pointDistance(focusPoint, associatedArrowPoint) <
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
) {
return false;
}
}
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap,
);
// Check if the focus point is within the element's shape bounds
// Endpoint dragging takes precedence
return (
pointDistance(focusPoint, arrowPoint) >=
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
};
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
switchToInsideBinding: boolean,
) => {
const pointUpdates = new Map();
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding = arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
// Update the targeted bindings
const boundToSameElement =
bindableElement &&
adjacentBinding &&
currentBinding.elementId === adjacentBinding.elementId;
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "inside",
};
} else {
currentBinding = {
...currentBinding,
mode: "orbit",
};
}
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
const newPoint = updateBoundPoint(
arrow,
bindingField as "startBinding" | "endBinding",
currentBinding,
bindableElement,
elementsMap,
true,
);
if (newPoint) {
pointUpdates.set(pointIndex, { point: newPoint });
}
}
// Also update the adjacent end if it has a binding
if (adjacentBinding && adjacentBinding.mode === "orbit") {
const adjacentBindableElement = elementsMap.get(
adjacentBinding.elementId,
) as ExcalidrawBindableElement;
if (
adjacentBindableElement &&
isBindableElement(adjacentBindableElement) &&
isBindingEnabled(appState)
) {
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
};
} else {
adjacentBinding = {
...adjacentBinding,
mode: "orbit",
};
}
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
const adjacentNewPoint = updateBoundPoint(
arrow,
adjacentBindingField,
adjacentBinding,
adjacentBindableElement,
elementsMap,
);
if (adjacentNewPoint) {
pointUpdates.set(adjacentPointIndex, {
point: adjacentNewPoint,
});
}
}
}
if (pointUpdates.size > 0) {
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
[bindingField]: currentBinding,
[adjacentBindingField]: adjacentBinding,
});
}
};
export const handleFocusPointDrag = (
linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap,
pointerCoords: { x: number; y: number },
scene: Scene,
appState: AppState,
gridSize: NullableGridSize,
switchToInsideBinding: boolean,
) => {
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
// Sanity checks
if (
!arrow ||
!isBindingElement(arrow) ||
isElbowArrow(arrow) ||
!linearElementEditor.hoveredFocusPointBinding ||
!linearElementEditor.draggedFocusPointBinding
) {
return;
}
const isStartBinding =
linearElementEditor.draggedFocusPointBinding === "start";
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
const point = pointFrom<GlobalPoint>(
pointerCoords.x - offsetX,
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
if (hit && isBindingEnabled(appState)) {
// Break existing binding if bound to another shape or if binding is disabled
if (arrow[bindingField] && hit.id !== binding?.elementId) {
unbindBindingElement(
arrow,
linearElementEditor.draggedFocusPointBinding,
scene,
);
}
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
// If no existing binding, create it
if (!arrow[bindingField] || newMode) {
// Create a new binding if none exists
bindBindingElement(
arrow,
hit,
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
point,
);
}
// Update the binding's fixed point
scene.mutateElement(arrow, {
[bindingField]: {
...arrow[bindingField],
elementId: hit.id,
mode: newMode || arrow[bindingField]?.mode || "orbit",
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hit,
linearElementEditor.draggedFocusPointBinding,
elementsMap,
point,
),
},
});
} else {
// Not hovering any bindable element, move the arrow endpoint
const pointUpdates: PointsPositionUpdates = new Map();
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
pointUpdates.set(pointIndex, {
point: LinearElementEditor.createPointAt(
arrow,
elementsMap,
point[0],
point[1],
gridSize,
),
});
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
if (arrow[bindingField]) {
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
}
}
// Update the arrow endpoints
focusPointUpdate(
arrow,
hit,
isStartBinding,
elementsMap,
scene,
appState,
switchToInsideBinding,
);
if (hit && isBindingEnabled(appState)) {
moveArrowAboveBindable(
point,
arrow,
scene.getElementsIncludingDeleted(),
elementsMap,
scene,
hit,
);
}
};
export const handleFocusPointPointerDown = (
arrow: ExcalidrawArrowElement,
pointerDownState: { origin: { x: number; y: number } },
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "start",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
// Check end binding focus point (only if start not already hit)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "end",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
};
};
export const handleFocusPointPointerUp = (
linearElementEditor: LinearElementEditor,
scene: Scene,
) => {
invariant(
linearElementEditor.draggedFocusPointBinding,
"Must have a dragged focus point at pointer release",
);
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
linearElementEditor.elementId,
scene.getNonDeletedElementsMap(),
);
invariant(arrow, "Arrow must be in the scene");
// Clean up
const bindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "startBinding"
: "endBinding";
const otherBindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "endBinding"
: "startBinding";
const boundElementId = arrow[bindingKey]?.elementId;
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
const oldBoundElement =
boundElementId &&
scene
.getNonDeletedElements()
.find(
(element) =>
element.id !== boundElementId &&
element.id !== otherBoundElementId &&
isBindableElement(element) &&
element.boundElements?.find(({ id }) => id === arrow.id),
);
if (oldBoundElement) {
scene.mutateElement(oldBoundElement, {
boundElements: oldBoundElement.boundElements?.filter(
({ id }) => id !== arrow.id,
),
});
}
// Record the new bound element
const boundElement =
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
if (boundElement) {
scene.mutateElement(boundElement, {
boundElements: [
...(boundElement.boundElements || [])?.filter(
({ id }) => id !== arrow.id,
),
{
id: arrow.id,
type: "arrow",
},
],
});
}
};
export const handleFocusPointHover = (
arrow: ExcalidrawArrowElement,
scenePointerX: number,
scenePointerY: number,
scene: Scene,
appState: AppState,
): "start" | "end" | null => {
const elementsMap = scene.getNonDeletedElementsMap();
const pointerPos = pointFrom(scenePointerX, scenePointerY);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "start";
}
}
}
// Check end binding focus point (only if start not already hovered)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "end";
}
}
}
return null;
};
-45
View File
@@ -1,45 +0,0 @@
import type { App } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "../linearElementEditor";
import { handleFocusPointDrag } from "./focus";
export const maybeHandleArrowPointlikeDrag = ({
app,
event,
}: {
app: App;
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
}): boolean => {
const appState = app.state;
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
// Update focus point status if the binding mode is changing
if (appState.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointDrag(
appState.selectedLinearElement,
app.scene.getNonDeletedElementsMap(),
app.lastPointerMoveCoords,
app.scene,
appState,
app.getEffectiveGridSize(),
event.altKey,
);
return true;
} else if (
appState.selectedLinearElement.hoverPointIndex !== null &&
app.lastPointerMoveEvent &&
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
appState.selectedLinearElement.isDragging
) {
LinearElementEditor.handlePointDragging(
app.lastPointerMoveEvent,
app,
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
appState.selectedLinearElement,
);
return true;
}
}
return false;
};
+185 -308
View File
@@ -15,7 +15,6 @@ import {
pointFrom, pointFrom,
pointFromVector, pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual,
vectorFromPoint, vectorFromPoint,
vectorNormalize, vectorNormalize,
vectorScale, vectorScale,
@@ -27,11 +26,14 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/common";
import { getCenterForBounds } from "./bounds"; import {
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
getAllHoveredElementAtPoint, getAllHoveredElementAtPoint,
getHoveredElementForBinding, getHoveredElementForBinding,
hitElementItself,
intersectElementWithLineSegment, intersectElementWithLineSegment,
isBindableElementInsideOtherBindable, isBindableElementInsideOtherBindable,
isPointInElement, isPointInElement,
@@ -110,10 +112,8 @@ export type BindingStrategy =
* *
* IMPORTANT: currently must be > 0 (this also applies to the computed gap) * IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/ */
export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP = 10;
export const BASE_BINDING_GAP_ELBOW = 5; export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = ( export const getBindingGap = (
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
@@ -143,9 +143,7 @@ export const shouldEnableBindingForPointerEvent = (
return !event[KEYS.CTRL_OR_CMD]; return !event[KEYS.CTRL_OR_CMD];
}; };
export const isBindingEnabled = (appState: { export const isBindingEnabled = (appState: AppState): boolean => {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
return appState.isBindingEnabled; return appState.isBindingEnabled;
}; };
@@ -259,7 +257,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(zoom), (element) => maxBindingDistance_simple(zoom),
); );
const current = hit const current = hit
@@ -684,7 +682,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(appState.zoom), (e) => maxBindingDistance_simple(appState.zoom),
); );
const pointInElement = const pointInElement =
hit && hit &&
@@ -711,13 +709,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
const otherFocusPointIsInElement = const otherFocusPointIsInElement =
otherBindableElement && otherBindableElement &&
otherFocusPoint && otherFocusPoint &&
hitElementItself({ isPointInElement(otherFocusPoint, otherBindableElement, elementsMap);
point: otherFocusPoint,
element: otherBindableElement,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) { if (otherBinding && otherBinding.elementId === hit?.id) {
@@ -797,7 +789,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit, hit,
startDragged ? "start" : "end", startDragged ? "start" : "end",
elementsMap, elementsMap,
appState.zoom,
) || globalPoint, ) || globalPoint,
} }
: { mode: null }; : { mode: null };
@@ -807,24 +798,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? -1 : 0, startDragged ? -1 : 0,
elementsMap, elementsMap,
); );
const pointIsCloseToOtherElement =
otherFocusPoint && const other: BindingStrategy =
otherBindableElement && otherBindableElement &&
hitElementItself({ !otherFocusPointIsInElement &&
point: globalPoint, appState.selectedLinearElement?.initialState.altFocusPoint
element: otherBindableElement,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
overrideShouldTestInside: true,
});
const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "inside";
const other: BindingStrategy = !otherNeverOverride
? otherBindableElement &&
!otherFocusPointIsInElement &&
!pointIsCloseToOtherElement &&
appState.selectedLinearElement?.initialState.altFocusPoint
? { ? {
mode: "orbit", mode: "orbit",
element: otherBindableElement, element: otherBindableElement,
@@ -841,11 +819,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
otherBindableElement, otherBindableElement,
startDragged ? "end" : "start", startDragged ? "end" : "start",
elementsMap, elementsMap,
appState.zoom,
) || otherEndpoint, ) || otherEndpoint,
} }
: { mode: undefined } : { mode: undefined };
: { mode: undefined };
return { return {
start: startDragged ? current : other, start: startDragged ? current : other,
@@ -1109,7 +1085,7 @@ export const updateBoundElements = (
}); });
} }
const visitor = (element: ExcalidrawElement | undefined) => { boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isArrowElement(element) || element.isDeleted) { if (!isArrowElement(element) || element.isDeleted) {
return; return;
} }
@@ -1181,71 +1157,7 @@ export const updateBoundElements = (
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, scene, false); handleBindTextResize(element, scene, false);
} }
}; });
boundElementsVisitor(elementsMap, changedElement, visitor);
};
const updateArrowBindings = (
latestElement: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
) => {
invariant(
!isElbowArrow(latestElement),
"Elbow arrows not supported for indirect updates",
);
const binding = latestElement[startOrEnd];
const bindableElement =
binding &&
(elementsMap.get(binding.elementId) as ExcalidrawBindableElement);
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
latestElement,
startOrEnd === "startBinding" ? 0 : -1,
elementsMap,
);
const hit =
bindableElement &&
hitElementItself({
element: bindableElement,
point,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
});
const strategyName = startOrEnd === "startBinding" ? "start" : "end";
unbindBindingElement(latestElement, strategyName, scene);
if (hit) {
const pointIdx =
startOrEnd === "startBinding" ? 0 : latestElement.points.length - 1;
const localPoint = latestElement.points[pointIdx];
const strategy =
getBindingStrategyForDraggingBindingElementEndpoints_simple(
latestElement,
new Map([[pointIdx, { point: localPoint }]]),
point[0],
point[1],
elementsMap,
scene.getNonDeletedElements(),
appState,
);
if (
strategy[strategyName] &&
strategy[strategyName].element?.id === bindableElement.id &&
strategy[strategyName].mode
) {
bindBindingElement(
latestElement,
bindableElement,
strategy[strategyName].mode,
strategyName,
scene,
strategy[strategyName].focusPoint,
);
}
}
}; };
export const updateBindings = ( export const updateBindings = (
@@ -1258,27 +1170,14 @@ export const updateBindings = (
}, },
) => { ) => {
if (isArrowElement(latestElement)) { if (isArrowElement(latestElement)) {
const elementsMap = scene.getNonDeletedElementsMap(); bindOrUnbindBindingElement(
latestElement,
if (latestElement.startBinding) { new Map(),
updateArrowBindings( Infinity,
latestElement, Infinity,
"startBinding", scene,
elementsMap, appState,
scene, );
appState,
);
}
if (latestElement.endBinding) {
updateArrowBindings(
latestElement,
"endBinding",
elementsMap,
scene,
appState,
);
}
} else { } else {
updateBoundElements(latestElement, scene, { updateBoundElements(latestElement, scene, {
...options, ...options,
@@ -1392,16 +1291,14 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = snapToMid( const snapPoint = snapToMid(
arrowElement,
bindableElement, bindableElement,
elementsMap, elementsMap,
edgePoint, edgePoint,
0.05,
arrowElement,
); );
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : resolved[0], isHorizontal ? bindableCenter[0] : snapPoint[0],
!isHorizontal ? bindableCenter[1] : resolved[1], !isHorizontal ? bindableCenter[1] : snapPoint[1],
); );
const intersector = const intersector =
customIntersector ?? customIntersector ??
@@ -1409,7 +1306,7 @@ export const bindPointToSnapToElementOutline = (
otherPoint, otherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(resolved, otherPoint)), vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2, Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
otherPoint, otherPoint,
@@ -1424,14 +1321,14 @@ export const bindPointToSnapToElementOutline = (
if (!intersection) { if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>( const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? bindableCenter[0] : resolved[0], !isHorizontal ? bindableCenter[0] : snapPoint[0],
isHorizontal ? bindableCenter[1] : resolved[1], isHorizontal ? bindableCenter[1] : snapPoint[1],
); );
const anotherIntersector = lineSegment( const anotherIntersector = lineSegment(
anotherPoint, anotherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(resolved, anotherPoint)), vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2, Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
anotherPoint, anotherPoint,
@@ -1578,18 +1475,18 @@ export const avoidRectangularCorner = (
return p; return p;
}; };
export const snapToMid = ( const snapToMid = (
arrowElement: ExcalidrawArrowElement,
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
arrowElement?: ExcalidrawArrowElement, ): GlobalPoint => {
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget; const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1); const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0; const bindingGap = getBindingGap(bindTarget, arrowElement);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
@@ -1598,7 +1495,7 @@ export const snapToMid = (
// Too close to the center makes it hard to resolve direction precisely // Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) { if (pointDistance(center, nonRotated) < bindingGap) {
return undefined; return p;
} }
if ( if (
@@ -1607,8 +1504,8 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThreshold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(x - bindingGap, center[1]), pointFrom(x - bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1618,11 +1515,7 @@ export const snapToMid = (
nonRotated[0] < center[0] + horizontalThreshold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle);
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold && nonRotated[1] > center[1] - verticalThreshold &&
@@ -1630,7 +1523,7 @@ export const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]), pointFrom(x + width + bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1641,7 +1534,7 @@ export const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y + height + bindingGap), pointFrom(center[0], y + height + bindingGap),
center, center,
angle, angle,
); );
@@ -1690,44 +1583,13 @@ export const snapToMid = (
} }
} }
return undefined; return p;
}; };
const extractBinding = ( const compareElementArea = (
arrow: ExcalidrawArrowElement, a: ExcalidrawBindableElement,
startOrEnd: "startBinding" | "endBinding", b: ExcalidrawBindableElement,
elementsMap: ElementsMap, ) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2);
) => {
const binding = arrow[startOrEnd];
if (!binding) {
return {
element: null,
fixedPoint: null,
focusPoint: null,
binding,
mode: null,
};
}
const element = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
return {
element,
fixedPoint: binding.fixedPoint,
focusPoint: getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint),
element,
elementsMap,
),
binding,
mode: binding.mode,
};
};
const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height;
export const updateBoundPoint = ( export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
@@ -1735,151 +1597,162 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined, binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
dragging?: boolean, customIntersector?: LineSegment<GlobalPoint>,
): LocalPoint | null => { ): LocalPoint | null => {
if ( if (
binding == null || binding == null ||
// We only need to update the other end if this is a 2 point line element // We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && arrow.points.length > 2) || (binding.elementId !== bindableElement.id && arrow.points.length > 2)
// Initial arrow created on pointer down needs to not update the points
pointsEqual(
arrow.points[arrow.points.length - 1],
pointFrom<LocalPoint>(0, 0),
)
) { ) {
return null; return null;
} }
const focusPoint = getGlobalFixedPointForBindableElement( const global = getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint), normalizeFixedPoint(binding.fixedPoint),
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
const pointIndex =
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1;
const elbowed = isElbowArrow(arrow);
const otherBinding =
startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding;
const otherBindableElement =
otherBinding &&
(elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement);
const bounds = getElementBounds(bindableElement, elementsMap);
const otherBounds =
otherBindableElement && getElementBounds(otherBindableElement, elementsMap);
const isLargerThanOther =
otherBindableElement &&
compareElementArea(bindableElement, otherBindableElement) <
// if both shapes the same size, pretend the other is larger
(startOrEnd === "endBinding" ? 1 : 0);
const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds);
// 0. Short-circuit for inside binding as it doesn't require any // GOAL: If the arrow becomes too short, we want to jump the arrow endpoints
// calculations and is not affected by other bindings // to the exact focus points on the elements.
if (binding.mode === "inside") { // INTUITION: We're not interested in the exacts length of the arrow (which
return LinearElementEditor.createPointAt( // will change if we change where we route it), we want to know the length of
arrow, // the part which lies outside of both shapes and consider that as a trigger
elementsMap, // to change where we point the arrow. Avoids jumping the arrow in and out
focusPoint[0], // at every frame.
focusPoint[1], let arrowTooShort = false;
null,
);
}
const { element: otherBindable, focusPoint: otherFocusPoint } =
extractBinding(
arrow,
startOrEnd === "startBinding" ? "endBinding" : "startBinding",
elementsMap,
);
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "startBinding" ? -1 : 0,
elementsMap,
);
const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
const intersector =
otherFocusPointOrArrowPoint &&
lineSegment(focusPoint, otherFocusPointOrArrowPoint);
const otherOutlinePoint =
otherBindable &&
intersector &&
intersectElementWithLineSegment(
otherBindable,
elementsMap,
intersector,
getBindingGap(otherBindable, arrow),
).sort(
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
)[0];
const outlinePoint =
intersector &&
intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
getBindingGap(bindableElement, arrow),
).sort(
(a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
pointDistanceSq(b, otherFocusPointOrArrowPoint),
)[0];
const startHasArrowhead = arrow.startArrowhead !== null;
const endHasArrowhead = arrow.endArrowhead !== null;
const resolvedTarget =
(!startHasArrowhead && !endHasArrowhead) ||
(startOrEnd === "startBinding" && startHasArrowhead) ||
(startOrEnd === "endBinding" && endHasArrowhead)
? focusPoint
: outlinePoint || focusPoint;
// 1. Handle case when the outline point (or focus point) is inside
// the other shape by short-circuiting to the focus point, otherwise
// the arrow would invert
if ( if (
otherBindable && !isOverlapping &&
outlinePoint && !elbowed &&
!dragging && arrow.startBinding &&
// Arbitrary threshold to handle wireframing use cases arrow.endBinding &&
elementArea(otherBindable) < elementArea(bindableElement) * 2 && otherBindableElement &&
hitElementItself({ arrow.points.length === 2
element: otherBindable,
point: outlinePoint,
elementsMap,
threshold: getBindingGap(otherBindable, arrow),
overrideShouldTestInside: true,
})
) { ) {
return LinearElementEditor.createPointAt( const startFocusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const endFocusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const segment = lineSegment(startFocusPoint, endFocusPoint);
const startIntersection = intersectElementWithLineSegment(
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
const endIntersection = intersectElementWithLineSegment(
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
if (startIntersection.length > 0 && endIntersection.length > 0) {
const len = pointDistance(startIntersection[0], endIntersection[0]);
arrowTooShort = len < 40;
}
}
const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther;
let _customIntersector = customIntersector;
if (!elbowed && !_customIntersector) {
const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords(
arrow, arrow,
elementsMap, elementsMap,
resolvedTarget[0], );
resolvedTarget[1], const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
null, const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(arrow, bindableElement, elementsMap, global)
: global;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0],
arrow.y +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1],
),
center,
arrow.angle as Radians,
);
const bindingGap = getBindingGap(bindableElement, arrow);
const halfVector = vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) +
bindingGap * 2,
);
_customIntersector = lineSegment(
pointFromVector(halfVector, adjacentPoint),
pointFromVector(vectorScale(halfVector, -1), adjacentPoint),
); );
} }
const otherTargetPoint = otherBindable const maybeOutlineGlobal =
? otherOutlinePoint || otherFocusPoint || otherArrowPoint binding.mode === "orbit" && bindableElement
: otherArrowPoint; ? isNested
const arrowTooShort = ? global
pointDistance(otherTargetPoint, outlinePoint || focusPoint) <= : bindPointToSnapToElementOutline(
BASE_ARROW_MIN_LENGTH; {
...arrow,
points: [
pointIndex === 0
? LinearElementEditor.createPointAt(
arrow,
elementsMap,
global[0],
global[1],
null,
)
: arrow.points[0],
...arrow.points.slice(1, -1),
pointIndex === arrow.points.length - 1
? LinearElementEditor.createPointAt(
arrow,
elementsMap,
global[0],
global[1],
null,
)
: arrow.points[arrow.points.length - 1],
],
},
bindableElement,
pointIndex === 0 ? "start" : "end",
elementsMap,
_customIntersector,
)
: global;
// 2. If the arrow is unconnected at the other end, just check arrow size
// and short-circuit to the focus point if the arrow is too short to
// avoid inversion
if (!otherBindable) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1],
null,
);
}
// 3. If the arrow is too short while connected on both ends and
// the other arrow endpoint will not be inside the bindable, just
// check the arrow size and make a decision based on that
if (arrowTooShort) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
resolvedTarget?.[0] || focusPoint[0],
resolvedTarget?.[1] || focusPoint[1],
null,
);
}
// 4. In the general case, snap to the outline if possible
return LinearElementEditor.createPointAt( return LinearElementEditor.createPointAt(
arrow, arrow,
elementsMap, elementsMap,
outlinePoint?.[0] || focusPoint[0], maybeOutlineGlobal[0],
outlinePoint?.[1] || focusPoint[1], maybeOutlineGlobal[1],
null, null,
); );
}; };
@@ -1929,7 +1802,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const edgePoint: GlobalPoint = focusPoint const edgePoint = focusPoint
? focusPoint ? focusPoint
: LinearElementEditor.getPointAtIndexGlobalCoordinates( : LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement, linearElement,
@@ -1937,7 +1810,11 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap, elementsMap,
); );
const elementCenter = elementCenterPoint(hoveredElement, elementsMap); // Convert the global point to element-local coordinates
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
// Rotate the point to account for element rotation // Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads( const nonRotatedPoint = pointRotateRads(
+1 -2
View File
@@ -897,7 +897,6 @@ export const getArrowheadPoints = (
return [x2, y2, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts
const generateLinearElementShape = ( const generateLinearElementShape = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
): Drawable => { ): Drawable => {
@@ -955,7 +954,7 @@ const getLinearElementRotatedBounds = (
} }
// first element is always the curve // first element is always the curve
const cachedShape = ShapeCache.get(element, null)?.[0]; const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element); const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape); const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) => const transformXY = ([x, y]: GlobalPoint) =>
+6 -97
View File
@@ -59,11 +59,8 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
@@ -78,12 +75,7 @@ import type {
} from "./types"; } from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if ( if (element.type === "arrow") {
element.type === "arrow" ||
// frame elements should ignore inside hit test even if background is not
// transparent, so we can select children easily
isFrameLikeElement(element)
) {
return false; return false;
} }
@@ -113,12 +105,6 @@ export type HitTestArgs = {
overrideShouldTestInside?: boolean; overrideShouldTestInside?: boolean;
}; };
let cachedPoint: GlobalPoint | null = null;
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
export const hitElementItself = ({ export const hitElementItself = ({
point, point,
element, element,
@@ -127,24 +113,6 @@ export const hitElementItself = ({
frameNameBound = null, frameNameBound = null,
overrideShouldTestInside = false, overrideShouldTestInside = false,
}: HitTestArgs) => { }: HitTestArgs) => {
// Return cached result if the same point and element version is tested again
if (
cachedPoint &&
pointsEqual(point, cachedPoint) &&
cachedThreshold <= threshold &&
overrideShouldTestInside === cachedOverrideShouldTestInside
) {
const derefElement = cachedElement?.deref();
if (
derefElement &&
derefElement.id === element.id &&
derefElement.version === element.version &&
derefElement.versionNonce === element.versionNonce
) {
return cachedHit;
}
}
// Hit test against a frame's name // Hit test against a frame's name
const hitFrameName = frameNameBound const hitFrameName = frameNameBound
? isPointWithinBounds( ? isPointWithinBounds(
@@ -185,16 +153,7 @@ export const hitElementItself = ({
isPointOnElementOutline(point, element, elementsMap, threshold) isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold); : isPointOnElementOutline(point, element, elementsMap, threshold);
const result = hitElement || hitFrameName; return hitElement || hitFrameName;
// Cache end result
cachedPoint = point;
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedHit = result;
return result;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
@@ -298,7 +257,7 @@ export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement>[] => { ): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = []; const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -314,7 +273,7 @@ export const getAllHoveredElementAtPoint = (
if ( if (
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance) bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
) { ) {
candidateElements.push(element); candidateElements.push(element);
@@ -331,13 +290,13 @@ export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint( const candidateElements = getAllHoveredElementAtPoint(
point, point,
elements, elements,
elementsMap, elementsMap,
tolerance, toleranceFn,
); );
if (!candidateElements || candidateElements.length === 0) { if (!candidateElements || candidateElements.length === 0) {
@@ -356,56 +315,6 @@ export const getHoveredElementForBinding = (
.pop() as NonDeleted<ExcalidrawBindableElement>; .pop() as NonDeleted<ExcalidrawBindableElement>;
}; };
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): ExcalidrawBindableElement | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
}
}
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
};
/** /**
* Intersect a line with an element for binding test * Intersect a line with an element for binding test
* *
+9 -20
View File
@@ -1,12 +1,10 @@
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups"; import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution { export interface Distribution {
@@ -19,7 +17,6 @@ export const distributeElements = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
distribution: Distribution, distribution: Distribution,
appState: Readonly<AppState>, appState: Readonly<AppState>,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const [start, mid, end, extent] = const [start, mid, end, extent] =
distribution.axis === "x" distribution.axis === "x"
@@ -69,16 +66,12 @@ export const distributeElements = (
translation[distribution.axis] = pos - box[mid]; translation[distribution.axis] = pos - box[mid];
} }
return group.map((element) => { return group.map((element) =>
const updatedElement = scene.mutateElement(element, { newElementWith(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
updateBoundElements(element, scene, { );
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
} }
@@ -97,15 +90,11 @@ export const distributeElements = (
pos += step; pos += step;
pos += box[extent]; pos += box[extent];
return group.map((element) => { return group.map((element) =>
const updatedElement = scene.mutateElement(element, { newElementWith(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
updateBoundElements(element, scene, { );
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
}; };
+1 -1
View File
@@ -2276,7 +2276,7 @@ const getHoveredElement = (
origPoint, origPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(zoom), (element) => maxBindingDistance_simple(zoom),
); );
}; };
+2 -79
View File
@@ -56,7 +56,7 @@ const RE_REDDIT =
const RE_REDDIT_EMBED = const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeLikeTimestamp = (url: string): number => { const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined; let timeParam: string | null | undefined;
try { try {
@@ -85,57 +85,11 @@ const parseYouTubeLikeTimestamp = (url: string): number => {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
}; };
const parseGoogleDriveVideoLink = (
url: string,
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
const hostname = urlObj.hostname.replace(/^www\./, "");
if (hostname !== "drive.google.com") {
return null;
}
let fileId: string | null = null;
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
if (pathMatch?.[1]) {
fileId = pathMatch[1];
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
// Shared Drive links can be emitted as:
// - /open?id=<fileId> (common "open in Drive" format)
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
fileId = urlObj.searchParams.get("id");
}
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
return null;
}
// Some Drive share links include `resourcekey` for access to link-shared
// files; preserve it in the preview URL so embeds keep working.
const resourceKey = urlObj.searchParams.get("resourcekey");
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
return {
fileId,
resourceKey:
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
? resourceKey
: undefined,
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
// normalize to seconds for a stable preview URL.
timestamp: timestamp > 0 ? timestamp : undefined,
};
} catch (error) {
return null;
}
};
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"link.excalidraw.com", "link.excalidraw.com",
"gist.github.com", "gist.github.com",
@@ -154,7 +108,6 @@ const ALLOW_SAME_ORIGIN = new Set([
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"twitter.com", "twitter.com",
"x.com", "x.com",
@@ -189,7 +142,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) { if (ytLink?.[2]) {
const startTime = parseYouTubeLikeTimestamp(originalLink); const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``; const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts"); const isPortrait = link.includes("shorts");
type = "video"; type = "video";
@@ -248,36 +201,6 @@ export const getEmbedLink = (
}; };
} }
const googleDriveVideo = parseGoogleDriveVideoLink(link);
if (googleDriveVideo) {
type = "video";
const searchParams = new URLSearchParams();
if (googleDriveVideo.resourceKey) {
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
}
if (googleDriveVideo.timestamp) {
searchParams.set("t", `${googleDriveVideo.timestamp}`);
}
const search = searchParams.toString();
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
search ? `?${search}` : ""
}`;
aspectRatio = { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
if (figmaLink) { if (figmaLink) {
type = "generic"; type = "generic";
-3
View File
@@ -70,7 +70,6 @@ export * from "./elbowArrow";
export * from "./elementLink"; export * from "./elementLink";
export * from "./embeddable"; export * from "./embeddable";
export * from "./flowchart"; export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex"; export * from "./fractionalIndex";
export * from "./frame"; export * from "./frame";
export * from "./groups"; export * from "./groups";
@@ -83,7 +82,6 @@ export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
export * from "./schema";
export * from "./Scene"; export * from "./Scene";
export * from "./selection"; export * from "./selection";
export * from "./shape"; export * from "./shape";
@@ -99,4 +97,3 @@ export * from "./transformHandles";
export * from "./typeChecks"; export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers";
+38 -128
View File
@@ -9,6 +9,7 @@ import {
vectorFromPoint, vectorFromPoint,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
lineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -25,7 +26,6 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
getSnapOutlineMidPoint,
isPathALoop, isPathALoop,
moveArrowAboveBindable, moveArrowAboveBindable,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -48,7 +48,6 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
import { import {
@@ -150,8 +149,6 @@ export class LinearElementEditor {
public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly hoveredFocusPointBinding: "start" | "end" | null;
public readonly draggedFocusPointBinding: "start" | "end" | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null; public readonly customLineAngle: number | null;
public readonly isEditing: boolean; public readonly isEditing: boolean;
@@ -197,8 +194,6 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.hoveredFocusPointBinding = null;
this.draggedFocusPointBinding = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null; this.customLineAngle = null;
this.isEditing = isEditing; this.isEditing = isEditing;
@@ -356,7 +351,6 @@ export class LinearElementEditor {
app, app,
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
event.altKey, event.altKey,
linearElementEditor,
); );
LinearElementEditor.movePoints(element, app.scene, positions, { LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -410,14 +404,13 @@ export class LinearElementEditor {
altFocusPoint: altFocusPoint:
!linearElementEditor.initialState.altFocusPoint && !linearElementEditor.initialState.altFocusPoint &&
startBindingElement && startBindingElement &&
updates?.suggestedBinding?.element.id !== startBindingElement.id updates?.suggestedBinding?.id !== startBindingElement.id
? projectFixedPointOntoDiagonal( ? projectFixedPointOntoDiagonal(
element, element,
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement, startBindingElement,
"start", "start",
elementsMap, elementsMap,
app.state.zoom,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -535,7 +528,6 @@ export class LinearElementEditor {
app, app,
shouldRotateWithDiscreteAngle(event) && singlePointDragged, shouldRotateWithDiscreteAngle(event) && singlePointDragged,
event.altKey, event.altKey,
linearElementEditor,
); );
LinearElementEditor.movePoints(element, app.scene, positions, { LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -611,11 +603,11 @@ export class LinearElementEditor {
const altFocusPointBindableElement = const altFocusPointBindableElement =
endIsSelected && // The "other" end (i.e. "end") is dragged endIsSelected && // The "other" end (i.e. "end") is dragged
startBindingElement && startBindingElement &&
updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
? startBindingElement ? startBindingElement
: startIsSelected && // The "other" end (i.e. "start") is dragged : startIsSelected && // The "other" end (i.e. "start") is dragged
endBindingElement && endBindingElement &&
updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
? endBindingElement ? endBindingElement
: null; : null;
@@ -635,7 +627,6 @@ export class LinearElementEditor {
altFocusPointBindableElement, altFocusPointBindableElement,
"start", "start",
elementsMap, elementsMap,
app.state.zoom,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -733,6 +724,7 @@ export class LinearElementEditor {
? [pointerDownState.lastClickedPoint] ? [pointerDownState.lastClickedPoint]
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null, customLineAngle: null,
initialState: { initialState: {
...editingLinearElement.initialState, ...editingLinearElement.initialState,
@@ -2085,7 +2077,6 @@ const pointDraggingUpdates = (
app: AppClassProperties, app: AppClassProperties,
angleLocked: boolean, angleLocked: boolean,
altKey: boolean, altKey: boolean,
linearElementEditor: LinearElementEditor,
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates; updates?: PointMoveOtherUpdates;
@@ -2133,89 +2124,18 @@ const pointDraggingUpdates = (
); );
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const suggestedBindingElement = startIsDragged
? start.element
: endIsDragged
? end.element
: null;
return { return {
positions: naiveDraggingPoints, positions: naiveDraggingPoints,
updates: { updates: {
suggestedBinding: suggestedBindingElement suggestedBinding: startIsDragged
? { ? start.element
element: suggestedBindingElement, : endIsDragged
midPoint: snapToMid( ? end.element
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
}
: null, : null,
}, },
}; };
} }
// Handle the case where neither endpoint is being dragged
// but we need to update bound endpoints
if (!startIsDragged && !endIsDragged) {
const nextArrow = {
...element,
points: element.points.map((p, idx) => {
return naiveDraggingPoints.get(idx)?.point ?? p;
}),
};
const positions = new Map(naiveDraggingPoints);
if (element.startBinding) {
const startBindable = elementsMap.get(element.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (startBindable) {
const startPoint =
updateBoundPoint(
nextArrow,
"startBinding",
element.startBinding,
startBindable,
elementsMap,
) ?? null;
if (startPoint) {
positions.set(0, { point: startPoint, isDragging: true });
}
}
}
if (element.endBinding) {
const endBindable = elementsMap.get(element.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (endBindable) {
const endPoint =
updateBoundPoint(
nextArrow,
"endBinding",
element.endBinding,
endBindable,
elementsMap,
) ?? null;
if (endPoint) {
positions.set(element.points.length - 1, {
point: endPoint,
isDragging: true,
});
}
}
}
return {
positions,
};
}
if (startIsDragged === endIsDragged) { if (startIsDragged === endIsDragged) {
return { return {
positions: naiveDraggingPoints, positions: naiveDraggingPoints,
@@ -2246,20 +2166,7 @@ const pointDraggingUpdates = (
(updates.startBinding.mode === "orbit" || (updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS")) !getFeatureFlag("COMPLEX_BINDINGS"))
) { ) {
updates.suggestedBinding = start.element updates.suggestedBinding = start.element;
? {
element: start.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
start.element,
elementsMap,
app.state.zoom,
),
}
: null;
} }
} else if (startIsDragged) { } else if (startIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding; updates.suggestedBinding = app.state.suggestedBinding;
@@ -2285,20 +2192,7 @@ const pointDraggingUpdates = (
(updates.endBinding.mode === "orbit" || (updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS")) !getFeatureFlag("COMPLEX_BINDINGS"))
) { ) {
updates.suggestedBinding = end.element updates.suggestedBinding = end.element;
? {
element: end.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
end.element,
elementsMap,
app.state.zoom,
),
}
: null;
} }
} else if (endIsDragged) { } else if (endIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding; updates.suggestedBinding = app.state.suggestedBinding;
@@ -2338,6 +2232,19 @@ const pointDraggingUpdates = (
: updates.endBinding, : updates.endBinding,
}; };
// We need to use a custom intersector to ensure that if there is a big "jump"
// in the arrow's position, we can position it with outline avoidance
// pixel-perfectly and avoid "dancing" arrows.
// NOTE: Direction matters here, so we create two intersectors
const startCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(start.focusPoint, end.focusPoint)
: undefined;
const endCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(end.focusPoint, start.focusPoint)
: undefined;
// Needed to handle a special case where an existing arrow is dragged over // Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side // the same element it is bound to on the other side
const startIsDraggingOverEndElement = const startIsDraggingOverEndElement =
@@ -2373,7 +2280,7 @@ const pointDraggingUpdates = (
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
elementsMap, elementsMap,
endIsDragged, endCustomIntersector,
) || nextArrow.points[nextArrow.points.length - 1] ) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1]; : nextArrow.points[nextArrow.points.length - 1];
@@ -2396,7 +2303,7 @@ const pointDraggingUpdates = (
: startIsDraggingOverEndElement && : startIsDraggingOverEndElement &&
app.state.bindMode !== "inside" && app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS") getFeatureFlag("COMPLEX_BINDINGS")
? endLocalPoint ? nextArrow.points[nextArrow.points.length - 1]
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
element, element,
@@ -2404,18 +2311,15 @@ const pointDraggingUpdates = (
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
elementsMap, elementsMap,
startIsDragged, startCustomIntersector,
) || nextArrow.points[0] ) || nextArrow.points[0]
: nextArrow.points[0]; : nextArrow.points[0];
const endChanged = const endChanged =
!startIsDraggingOverEndElement && pointDistance(
!( endLocalPoint,
endIsDraggingOverStartElement && nextArrow.points[nextArrow.points.length - 1],
app.state.bindMode !== "inside" && ) !== 0;
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
const startChanged = const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2429,7 +2333,13 @@ const pointDraggingUpdates = (
const indices = Array.from(indicesSet); const indices = Array.from(indicesSet);
return { return {
updates, updates:
updates.startBinding || updates.suggestedBinding
? {
startBinding: updates.startBinding,
suggestedBinding: updates.suggestedBinding,
}
: undefined,
positions: new Map( positions: new Map(
indices.map((idx) => { indices.map((idx) => {
return [ return [
+1 -14
View File
@@ -11,7 +11,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./shape"; import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { ensureSchemaStateForElementType } from "./schema";
import { isElbowArrow } from "./typeChecks"; import { isElbowArrow } from "./typeChecks";
@@ -138,10 +137,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version = updates.version ?? element.version + 1; element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
element.schemaState = ensureSchemaStateForElementType(
element.schemaState,
element.type,
) as TElement["schemaState"];
return element; return element;
}; };
@@ -171,21 +166,13 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
const updatedElement = { return {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1, version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(), versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
}; };
return {
...updatedElement,
schemaState: ensureSchemaStateForElementType(
updatedElement.schemaState,
updatedElement.type,
),
};
}; };
/** /**
+22 -22
View File
@@ -20,9 +20,8 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { ensureSchemaStateForElementType } from "./schema";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement"; import { getBoundTextMaxWidth, getInitialTextMetrics } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -71,7 +70,6 @@ export type ElementConstructorOpts = MarkOptional<
| "roughness" | "roughness"
| "strokeWidth" | "strokeWidth"
| "roundness" | "roundness"
| "schemaState"
| "locked" | "locked"
| "opacity" | "opacity"
| "customData" | "customData"
@@ -146,7 +144,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
schemaState: ensureSchemaStateForElementType(rest.schemaState, type),
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false, isDeleted: false as false,
boundElements, boundElements,
@@ -239,27 +236,30 @@ const getTextElementPositionOffsets = (
}; };
}; };
export type NewTextElementOptions = {
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts;
export const newTextElement = ( export const newTextElement = (
opts: { opts: NewTextElementOptions,
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text); const normalizedText = normalizeText(opts.text);
const metrics = measureText( const originalText = opts.originalText ?? normalizedText;
text, const metrics = getInitialTextMetrics(
getFontString({ fontFamily, fontSize }), { ...opts, text: normalizedText },
lineHeight, fontFamily,
fontSize,
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -270,7 +270,7 @@ export const newTextElement = (
const textElementProps: ExcalidrawTextElement = { const textElementProps: ExcalidrawTextElement = {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
text, text: normalizedText,
fontSize, fontSize,
fontFamily, fontFamily,
textAlign, textAlign,
@@ -280,7 +280,7 @@ export const newTextElement = (
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: opts.originalText ?? text, originalText,
autoResize: opts.autoResize ?? true, autoResize: opts.autoResize ?? true,
lineHeight, lineHeight,
}; };
+140 -154
View File
@@ -1,4 +1,5 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { import {
type GlobalPoint, type GlobalPoint,
@@ -14,7 +15,6 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY, ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE, FRAME_STYLE,
DARK_THEME_FILTER,
MIME_TYPES, MIME_TYPES,
THEME, THEME,
distance, distance,
@@ -22,9 +22,6 @@ import {
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant, invariant,
isTransparent,
applyDarkModeFilter,
isSafari,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -79,11 +76,18 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ElementsMap, ElementsMap,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import type { StrokeOptions } from "perfect-freehand";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the colors look slightly
// desatured, alas...)
export const IMAGE_INVERT_FILTER =
"invert(100%) hue-rotate(180deg) saturate(1.25)";
const isPendingImageElement = ( const isPendingImageElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
@@ -91,6 +95,19 @@ const isPendingImageElement = (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
!renderConfig.imageCache.has(element.fileId); !renderConfig.imageCache.has(element.fileId);
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, renderConfig) &&
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
);
};
const getCanvasPadding = (element: ExcalidrawElement) => { const getCanvasPadding = (element: ExcalidrawElement) => {
switch (element.type) { switch (element.type) {
case "freedraw": case "freedraw":
@@ -255,6 +272,11 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas); const rc = rough.canvas(canvas);
// in dark theme, revert the image color filter
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig);
context.restore(); context.restore();
@@ -363,9 +385,8 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
const drawImagePlaceholder = ( const drawImagePlaceholder = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
theme: StaticCanvasRenderConfig["theme"],
) => { ) => {
context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7"; context.fillStyle = "#E7E7E7";
context.fillRect(0, 0, element.width, element.height); context.fillRect(0, 0, element.width, element.height);
const imageMinWidthOrHeight = Math.min(element.width, element.height); const imageMinWidthOrHeight = Math.min(element.width, element.height);
@@ -400,8 +421,7 @@ const drawElementOnCanvas = (
case "ellipse": { case "ellipse": {
context.lineJoin = "round"; context.lineJoin = "round";
context.lineCap = "round"; context.lineCap = "round";
rc.draw(ShapeCache.get(element)!);
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
break; break;
} }
case "arrow": case "arrow":
@@ -409,44 +429,33 @@ const drawElementOnCanvas = (
context.lineJoin = "round"; context.lineJoin = "round";
context.lineCap = "round"; context.lineCap = "round";
ShapeCache.generateElementShape(element, renderConfig).forEach( ShapeCache.get(element)!.forEach((shape) => {
(shape) => { rc.draw(shape);
rc.draw(shape); });
},
);
break; break;
} }
case "freedraw": { case "freedraw": {
// Draw directly to canvas // Draw directly to canvas
context.save(); context.save();
context.fillStyle = element.strokeColor;
const shapes = ShapeCache.generateElementShape(element, renderConfig); const path = getFreeDrawPath2D(element) as Path2D;
const fillShape = ShapeCache.get(element);
for (const shape of shapes) { if (fillShape) {
if (typeof shape === "string") { rc.draw(fillShape);
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
}
} }
context.fillStyle = element.strokeColor;
context.fill(path);
context.restore(); context.restore();
break; break;
} }
case "image": { case "image": {
context.save();
const cacheEntry =
element.fileId !== null
? renderConfig.imageCache.get(element.fileId)
: null;
const img = isInitializedImageElement(element) const img = isInitializedImageElement(element)
? cacheEntry?.image ? renderConfig.imageCache.get(element.fileId)?.image
: undefined; : undefined;
if (img != null && !(img instanceof Promise)) { if (img != null && !(img instanceof Promise)) {
if (element.roundness && context.roundRect) { if (element.roundness && context.roundRect) {
context.beginPath(); context.beginPath();
@@ -469,80 +478,20 @@ const drawElementOnCanvas = (
height: img.naturalHeight, height: img.naturalHeight,
}; };
const shouldInvertImage = context.drawImage(
renderConfig.theme === THEME.DARK && img,
cacheEntry?.mimeType === MIME_TYPES.svg; x,
y,
if (shouldInvertImage && isSafari) { width,
const devicePixelRatio = window.devicePixelRatio || 1; height,
const tempCanvas = document.createElement("canvas"); 0 /* hardcoded for the selection box*/,
tempCanvas.width = element.width * devicePixelRatio; 0,
tempCanvas.height = element.height * devicePixelRatio; element.width,
const tempContext = tempCanvas.getContext("2d"); element.height,
);
if (tempContext) {
tempContext.scale(devicePixelRatio, devicePixelRatio);
tempContext.drawImage(
img,
x,
y,
width,
height,
0,
0,
element.width,
element.height,
);
const imageData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
data[i + 1] = 255 - data[i + 1];
data[i + 2] = 255 - data[i + 2];
}
tempContext.putImageData(imageData, 0, 0);
context.drawImage(
tempCanvas,
0,
0,
tempCanvas.width,
tempCanvas.height,
0,
0,
element.width,
element.height,
);
}
} else {
if (shouldInvertImage) {
context.filter = DARK_THEME_FILTER;
}
context.drawImage(
img,
x,
y,
width,
height,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
}
} else { } else {
drawImagePlaceholder(element, context, renderConfig.theme); drawImagePlaceholder(element, context);
} }
context.restore();
break; break;
} }
default: { default: {
@@ -557,10 +506,7 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save(); context.save();
context.font = getFontString(element); context.font = getFontString(element);
context.fillStyle = context.fillStyle = element.strokeColor;
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign; context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
@@ -779,45 +725,6 @@ export const renderSelectionElement = (
context.restore(); context.restore();
}; };
export const renderFrameBackground = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
opts?: {
roundCorners?: boolean;
},
) => {
if (isTransparent(frame.backgroundColor)) {
return;
}
context.save();
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor;
const shouldRoundCorners = opts?.roundCorners ?? true;
if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.fill();
context.closePath();
} else {
context.fillRect(0, 0, frame.width, frame.height);
}
context.restore();
};
export const renderElement = ( export const renderElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap, elementsMap: RenderableElementsMap,
@@ -849,19 +756,15 @@ export const renderElement = (
element.x + appState.scrollX, element.x + appState.scrollX,
element.y + appState.scrollY, element.y + appState.scrollY,
); );
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = context.strokeStyle = FRAME_STYLE.strokeColor;
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames // TODO change later to only affect AI frames
if (isMagicFrameElement(element)) { if (isMagicFrameElement(element)) {
context.strokeStyle = context.strokeStyle =
appState.theme === THEME.LIGHT appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
? "#7affd7"
: applyDarkModeFilter("#1d8264");
} }
if (FRAME_STYLE.radius && context.roundRect) { if (FRAME_STYLE.radius && context.roundRect) {
@@ -884,6 +787,11 @@ export const renderElement = (
break; break;
} }
case "freedraw": { case "freedraw": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, null);
if (renderConfig.isExporting) { if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX; const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -927,6 +835,10 @@ export const renderElement = (
case "text": case "text":
case "iframe": case "iframe":
case "embeddable": { case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, renderConfig);
if (renderConfig.isExporting) { if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX; const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -949,6 +861,9 @@ export const renderElement = (
context.save(); context.save();
context.translate(cx, cy); context.translate(cx, cy);
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = "none";
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) { if (isArrowElement(element) && boundTextElement) {
@@ -1111,6 +1026,23 @@ export const renderElement = (
context.globalAlpha = 1; context.globalAlpha = 1;
}; };
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
const svgPathData = getFreeDrawSvgPath(element);
const path = new Path2D(svgPathData);
pathsCache.set(element, path);
return path;
}
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element);
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments( export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement, element: ExcalidrawFreeDrawElement,
points: [number, number][], points: [number, number][],
@@ -1166,3 +1098,57 @@ export function getFreedrawOutlineAsSegments(
], ],
); );
} }
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}
function med(A: number[], B: number[]) {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
}
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
function getSvgPathFromStroke(points: number[][]): string {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
}
-91
View File
@@ -1,91 +0,0 @@
/**
* Shared schema primitives used by element types and higher-level migrations.
*/
export const SCHEMA_INITIAL_TRACK_VERSION = 1 as const;
/** Core namespace reserved for built-in Excalidraw migrations. */
export const SCHEMA_CORE_NAMESPACE = "core" as const;
export type SchemaNamespace = typeof SCHEMA_CORE_NAMESPACE | `host.${string}`;
/**
* A schema track is an independent version line:
* - core tracks: "excalidraw.*"
* - host tracks: "host.<appId>.<track>"
*/
export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`;
export type ElementSchemaState = Readonly<{
tracks: Readonly<Record<string, number>>;
}>;
/** Core frame track id used by the frame background migration. */
export const CORE_FRAME_SCHEMA_TRACK = "excalidraw.shape.frame" as const;
/** Latest core track versions supported by this build. */
export const CORE_SUPPORTED_TRACKS = {
[CORE_FRAME_SCHEMA_TRACK]: 2,
} as const;
const getRequiredCoreTracksForElementType = (type: string) => {
if (type === "frame") {
return {
[CORE_FRAME_SCHEMA_TRACK]: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
} as const;
}
return {} as const;
};
const isValidTrackVersion = (version: unknown): version is number =>
typeof version === "number" &&
Number.isInteger(version) &&
version >= SCHEMA_INITIAL_TRACK_VERSION;
/**
* Ensures an element schema state is normalized and satisfies type defaults.
* Required core tracks are only ever bumped forward (never downgraded).
*/
export const ensureSchemaStateForElementType = (
schemaState: ElementSchemaState | undefined,
type: string,
): ElementSchemaState => {
const requiredTracks = getRequiredCoreTracksForElementType(type);
const currentTracks = schemaState?.tracks || {};
const nextTracks: Record<string, number> = {};
let didChange = !schemaState;
for (const [track, version] of Object.entries(
currentTracks as Record<string, unknown>,
)) {
if (isValidTrackVersion(version)) {
nextTracks[track] = version;
continue;
}
nextTracks[track] = SCHEMA_INITIAL_TRACK_VERSION;
didChange = true;
}
for (const [track, requiredVersion] of Object.entries(requiredTracks)) {
const currentVersion = nextTracks[track];
if (
!isValidTrackVersion(currentVersion) ||
currentVersion < requiredVersion
) {
nextTracks[track] = requiredVersion;
didChange = true;
}
}
if (!didChange) {
return schemaState!;
}
return { tracks: nextTracks };
};
/**
* Default schema state for newly created elements.
* New frames are created at the latest supported frame track version.
*/
export const getDefaultSchemaStateForElementType = (
type: string,
): ElementSchemaState => ensureSchemaStateForElementType(undefined, type);
+43 -144
View File
@@ -1,5 +1,4 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { import {
type GeometricShape, type GeometricShape,
@@ -18,12 +17,10 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ROUGHNESS, ROUGHNESS,
THEME,
isTransparent, isTransparent,
assertNever, assertNever,
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -39,7 +36,6 @@ import type {
import type { import type {
ElementShape, ElementShape,
ElementShapes, ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement"; import { elementWithCanvasCache } from "./renderElement";
@@ -56,6 +52,7 @@ import { getCornerRadius, isPathALoop } from "./utils";
import { headingForPointIsHorizontal } from "./heading"; import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement";
import { import {
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds, getCenterForBounds,
@@ -80,32 +77,29 @@ import type { Point as RoughPoint } from "roughjs/bin/geometry";
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
private static cache = new WeakMap< private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
ExcalidrawElement,
{ shape: ElementShape; theme: AppState["theme"] }
>();
/** /**
* Retrieves shape from cache if available. Use this only if shape * Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached. * is optional and you have a fallback in case it's not cached.
*/ */
public static get = <T extends ExcalidrawElement>( public static get = <T extends ExcalidrawElement>(element: T) => {
element: T, return ShapeCache.cache.get(
theme: AppState["theme"] | null, element,
) => { ) as T["type"] extends keyof ElementShapes
const cached = ShapeCache.cache.get(element); ? ElementShapes[T["type"]] | undefined
if (cached && (theme === null || cached.theme === theme)) { : ElementShape | undefined;
return cached.shape as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
}
return undefined;
}; };
public static delete = (element: ExcalidrawElement) => { public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element); ShapeCache.cache.delete(element);
elementWithCanvasCache.delete(element);
};
public static destroy = () => { public static destroy = () => {
ShapeCache.cache = new WeakMap(); ShapeCache.cache = new WeakMap();
@@ -123,13 +117,12 @@ export class ShapeCache {
isExporting: boolean; isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"]; canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus; embedsValidationStatus: EmbedsValidationStatus;
theme: AppState["theme"];
} | null, } | null,
) => { ) => {
// when exporting, always regenerated to guarantee the latest shape // when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting const cachedShape = renderConfig?.isExporting
? undefined ? undefined
: ShapeCache.get(element, renderConfig ? renderConfig.theme : null); : ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type, // `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate) // but it's considered a valid cache value (= do not regenerate)
@@ -139,25 +132,19 @@ export class ShapeCache {
elementWithCanvasCache.delete(element); elementWithCanvasCache.delete(element);
const shape = _generateElementShape( const shape = generateElementShape(
element, element,
ShapeCache.rg, ShapeCache.rg,
renderConfig || { renderConfig || {
isExporting: false, isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white, canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null, embedsValidationStatus: null,
theme: THEME.LIGHT,
}, },
) as T["type"] extends keyof ElementShapes ) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] ? ElementShapes[T["type"]]
: Drawable | null; : Drawable | null;
if (!renderConfig?.isExporting) { ShapeCache.cache.set(element, shape);
ShapeCache.cache.set(element, {
shape,
theme: renderConfig?.theme || THEME.LIGHT,
});
}
return shape; return shape;
}; };
@@ -193,7 +180,6 @@ function adjustRoughness(element: ExcalidrawElement): number {
export const generateRoughOptions = ( export const generateRoughOptions = (
element: ExcalidrawElement, element: ExcalidrawElement,
continuousPath = false, continuousPath = false,
isDarkMode: boolean = false,
): Options => { ): Options => {
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -218,9 +204,7 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: isDarkMode stroke: element.strokeColor,
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices: preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist, continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
@@ -234,8 +218,6 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
? undefined ? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor; : element.backgroundColor;
if (element.type === "ellipse") { if (element.type === "ellipse") {
options.curveFitting = 1; options.curveFitting = 1;
@@ -249,8 +231,6 @@ export const generateRoughOptions = (
options.fill = options.fill =
element.backgroundColor === "transparent" element.backgroundColor === "transparent"
? undefined ? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor; : element.backgroundColor;
} }
return options; return options;
@@ -304,7 +284,6 @@ const getArrowheadShapes = (
generator: RoughGenerator, generator: RoughGenerator,
options: Options, options: Options,
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean,
) => { ) => {
const arrowheadPoints = getArrowheadPoints( const arrowheadPoints = getArrowheadPoints(
element, element,
@@ -330,10 +309,6 @@ const getArrowheadShapes = (
return [generator.line(x3, y3, x4, y4, options)]; return [generator.line(x3, y3, x4, y4, options)];
}; };
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
switch (arrowhead) { switch (arrowhead) {
case "dot": case "dot":
case "circle": case "circle":
@@ -349,10 +324,10 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "circle_outline" arrowhead === "circle_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: strokeColor, : element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
stroke: strokeColor, stroke: element.strokeColor,
roughness: Math.min(0.5, options.roughness || 0), roughness: Math.min(0.5, options.roughness || 0),
}), }),
]; ];
@@ -377,7 +352,7 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "triangle_outline" arrowhead === "triangle_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: strokeColor, : element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0), roughness: Math.min(1, options.roughness || 0),
}, },
@@ -405,7 +380,7 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "diamond_outline" arrowhead === "diamond_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: strokeColor, : element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0), roughness: Math.min(1, options.roughness || 0),
}, },
@@ -627,22 +602,19 @@ export const generateLinearCollisionShape = (
* *
* @private * @private
*/ */
const _generateElementShape = ( const generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator, generator: RoughGenerator,
{ {
isExporting, isExporting,
canvasBackgroundColor, canvasBackgroundColor,
embedsValidationStatus, embedsValidationStatus,
theme,
}: { }: {
isExporting: boolean; isExporting: boolean;
canvasBackgroundColor: string; canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null; embedsValidationStatus: EmbedsValidationStatus | null;
theme?: AppState["theme"];
}, },
): ElementShape => { ): Drawable | Drawable[] | null => {
const isDarkMode = theme === THEME.DARK;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe": case "iframe":
@@ -668,7 +640,6 @@ const _generateElementShape = (
embedsValidationStatus, embedsValidationStatus,
), ),
true, true,
isDarkMode,
), ),
); );
} else { } else {
@@ -684,7 +655,6 @@ const _generateElementShape = (
embedsValidationStatus, embedsValidationStatus,
), ),
false, false,
isDarkMode,
), ),
); );
} }
@@ -722,7 +692,7 @@ const _generateElementShape = (
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${ C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius topY + horizontalRadius
}`, }`,
generateRoughOptions(element, true, isDarkMode), generateRoughOptions(element, true),
); );
} else { } else {
shape = generator.polygon( shape = generator.polygon(
@@ -732,7 +702,7 @@ const _generateElementShape = (
[bottomX, bottomY], [bottomX, bottomY],
[leftX, leftY], [leftX, leftY],
], ],
generateRoughOptions(element, false, isDarkMode), generateRoughOptions(element),
); );
} }
return shape; return shape;
@@ -743,14 +713,14 @@ const _generateElementShape = (
element.height / 2, element.height / 2,
element.width, element.width,
element.height, element.height,
generateRoughOptions(element, false, isDarkMode), generateRoughOptions(element),
); );
return shape; return shape;
} }
case "line": case "line":
case "arrow": { case "arrow": {
let shape: ElementShapes[typeof element.type]; let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element, false, isDarkMode); const options = generateRoughOptions(element);
// points array can be empty in the beginning, so it is important to add // points array can be empty in the beginning, so it is important to add
// initial position to it // initial position to it
@@ -775,7 +745,7 @@ const _generateElementShape = (
shape = [ shape = [
generator.path( generator.path(
generateElbowArrowShape(points, 16), generateElbowArrowShape(points, 16),
generateRoughOptions(element, true, isDarkMode), generateRoughOptions(element, true),
), ),
]; ];
} }
@@ -808,7 +778,6 @@ const _generateElementShape = (
generator, generator,
options, options,
canvasBackgroundColor, canvasBackgroundColor,
isDarkMode,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@@ -826,7 +795,6 @@ const _generateElementShape = (
generator, generator,
options, options,
canvasBackgroundColor, canvasBackgroundColor,
isDarkMode,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@@ -834,28 +802,23 @@ const _generateElementShape = (
return shape; return shape;
} }
case "freedraw": { case "freedraw": {
// oredered in terms of z-index [background, stroke] let shape: ElementShapes[typeof element.type];
const shapes: ElementShapes[typeof element.type] = []; generateFreeDrawShape(element);
// (1) background fill (rc shape), optional
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape // generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify( const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>, element.points as Mutable<LocalPoint[]>,
0.75, 0.75,
); );
shapes.push( shape = generator.curve(simplifiedPoints as [number, number][], {
generator.curve(simplifiedPoints as [number, number][], { ...generateRoughOptions(element),
...generateRoughOptions(element, false, isDarkMode), stroke: "none",
stroke: "none", });
}), } else {
); shape = null;
} }
return shape;
// (2) stroke
shapes.push(getFreeDrawSvgPath(element));
return shapes;
} }
case "frame": case "frame":
case "magicframe": case "magicframe":
@@ -962,7 +925,9 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
return getPolygonShape(element); return getPolygonShape(element);
case "arrow": case "arrow":
case "line": { case "line": {
const roughShape = ShapeCache.generateElementShape(element, null)[0]; const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element) return shouldTestInside(element)
@@ -1038,69 +1003,3 @@ export const toggleLinePolygonState = (
return ret; return ret;
}; };
// -----------------------------------------------------------------------------
// freedraw shape helper
// -----------------------------------------------------------------------------
// NOTE not cached (-> for SVG export)
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
return getSvgPathFromStroke(
getFreedrawOutlinePoints(element),
) as SVGPathString;
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
};
// -----------------------------------------------------------------------------
+26
View File
@@ -8,6 +8,8 @@ import {
getFontString, getFontString,
isProdEnv, isProdEnv,
invariant, invariant,
DEFAULT_FONT_FAMILY,
getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
@@ -30,6 +32,8 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { NewTextElementOptions } from "./newElement";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
@@ -40,6 +44,7 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -528,3 +533,24 @@ export const getTextFromElements = (
.join(separator); .join(separator);
return text; return text;
}; };
/** When text is already measured and wrapped, we want to respect those dimensions */
export const getInitialTextMetrics = (
text: NewTextElementOptions,
fontFamily: FontFamilyValues = DEFAULT_FONT_FAMILY,
fontSize: number = DEFAULT_FONT_SIZE,
) => {
const shouldUseProvidedDimensions =
text.autoResize === false && text.width && text.height;
return shouldUseProvidedDimensions
? {
width: text.width,
height: text.height,
}
: measureText(
text.text,
getFontString({ fontFamily, fontSize }),
text.lineHeight ?? getLineHeight(fontFamily),
);
};
+2 -12
View File
@@ -10,10 +10,8 @@ import {
arrayToMap, arrayToMap,
assertNever, assertNever,
cloneJSON, cloneJSON,
getFontString,
isDevEnv, isDevEnv,
toBrandedType, toBrandedType,
getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types"; import type { MarkOptional } from "@excalidraw/common/utility-types";
@@ -29,12 +27,11 @@ import {
newTextElement, newTextElement,
type ElementConstructorOpts, type ElementConstructorOpts,
} from "./newElement"; } from "./newElement";
import { measureText, normalizeText } from "./textMeasurements";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "./typeChecks";
import { syncInvalidIndices } from "./fractionalIndex"; import { syncInvalidIndices } from "./fractionalIndex";
import { redrawTextBoundingBox } from "./textElement"; import { getInitialTextMetrics, redrawTextBoundingBox } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
@@ -579,14 +576,7 @@ export const convertToExcalidrawElements = (
case "text": { case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = element?.lineHeight || getLineHeight(fontFamily); const metrics = getInitialTextMetrics(element, fontFamily, fontSize);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
normalizedText,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
excalidrawElement = newTextElement({ excalidrawElement = newTextElement({
width: metrics.width, width: metrics.width,
-4
View File
@@ -15,8 +15,6 @@ import type {
ValueOf, ValueOf,
} from "@excalidraw/common/utility-types"; } from "@excalidraw/common/utility-types";
import type { ElementSchemaState } from "./schema";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyKeys = keyof typeof FONT_FAMILY;
@@ -60,8 +58,6 @@ type _ExcalidrawElementBase = Readonly<{
/** Integer that is sequentially incremented on each change. Used to reconcile /** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */ elements during collaboration or when saving to server. */
version: number; version: number;
/** Per-track schema state used by migrations during restore. */
schemaState: ElementSchemaState;
/** Random integer that is regenerated on each change. /** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration, Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */ in case the versions (see above) are identical. */
+26 -149
View File
@@ -7,7 +7,6 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
bezierEquation,
curve, curve,
curveCatmullRomCubicApproxPoints, curveCatmullRomCubicApproxPoints,
curveOffsetPoints, curveOffsetPoints,
@@ -28,30 +27,19 @@ import {
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
AppState,
NormalizedZoomValue,
Zoom,
} from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape"; import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision"; import { isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
@@ -341,10 +329,24 @@ export function deconstructRectanguloidElement(
return shape; return shape;
} }
export function getDiamondBaseCorners( /**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): Curve<GlobalPoint>[] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element); getDiamondPoints(element);
const verticalRadius = element.roundness const verticalRadius = element.roundness
@@ -361,7 +363,7 @@ export function getDiamondBaseCorners(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
return [ const baseCorners = [
curve( curve(
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
right[0] - verticalRadius, right[0] - verticalRadius,
@@ -411,27 +413,6 @@ export function getDiamondBaseCorners(
), ),
), // TOP ), // TOP
]; ];
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const baseCorners = getDiamondBaseCorners(element, offset);
const corners = baseCorners.map( const corners = baseCorners.map(
(corner) => (corner) =>
@@ -589,128 +570,28 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo]; return [diagonalOne, diagonalTwo];
}; };
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
}),
);
return candidate;
};
export const projectFixedPointOntoDiagonal = ( export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement, arrow: ExcalidrawArrowElement,
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawBindableElement, element: ExcalidrawElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
): GlobalPoint | null => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points"); invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) { if (arrow.width < 3 && arrow.height < 3) {
return null; return null;
} }
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element, element,
elementsMap, elementsMap,
); );
// To avoid working with stale arrow state, we use the opposite focus point const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
// of the current endpoint, which will always be unchanged during moving of
// the endpoint. This is only needed when the arrow has only two points.
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2, startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap, elementsMap,
); );
if (arrow.points.length === 2) {
const otherBinding =
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
const otherBindable =
otherBinding &&
(elementsMap.get(otherBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const otherFocusPoint =
otherBinding &&
otherBindable &&
getGlobalFixedPointForBindableElement(
normalizeFixedPoint(otherBinding.fixedPoint),
otherBindable,
elementsMap,
);
if (otherFocusPoint) {
a = otherFocusPoint;
}
}
const b = pointFromVector<GlobalPoint>( const b = pointFromVector<GlobalPoint>(
vectorScale( vectorScale(
vectorFromPoint(point, a), vectorFromPoint(point, a),
@@ -722,22 +603,18 @@ export const projectFixedPointOntoDiagonal = (
), ),
a, a,
); );
const intersector = lineSegment<GlobalPoint>(b, a); const intersector = lineSegment<GlobalPoint>(point, b);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1); const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2); const d2 = p2 && pointDistance(a, p2);
let projection = null; let p = null;
if (d1 != null && d2 != null) { if (d1 != null && d2 != null) {
projection = d1 < d2 ? p1 : p2; p = d1 < d2 ? p1 : p2;
} else { } else {
projection = p1 || p2 || null; p = p1 || p2 || null;
} }
if (projection && isPointInElement(projection, element, elementsMap)) { return p && isPointInElement(p, element, elementsMap) ? p : null;
return projection;
}
return null;
}; };
+5 -4
View File
@@ -156,11 +156,12 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene, scene: Scene,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => { ): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit const hoveredElement = getHoveredElementForBinding(
? hit point,
: getHoveredElementForBinding(point, elements, elementsMap); elements,
elementsMap,
);
if (!hoveredElement) { if (!hoveredElement) {
return elements; return elements;
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />
+2 -229
View File
@@ -1,12 +1,9 @@
import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils";
import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision"; import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
@@ -28,6 +25,8 @@ describe("check rotated elements can be hit:", () => {
[-4, -302], [-4, -302],
] as LocalPoint[], ] as LocalPoint[],
}); });
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68), point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0], element: window.h.elements[0],
@@ -37,229 +36,3 @@ describe("check rotated elements can be hit:", () => {
expect(hit).toBe(true); expect(hit).toBe(true);
}); });
}); });
describe("frame hit testing", () => {
it.each(["transparent", "#ffffff"])(
"does not hit frame inside regardless of background color (%s)",
(backgroundColor) => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor,
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
},
);
it("hits frame outline", () => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(0, 50),
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element: API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
}),
threshold: Infinity,
elementsMap: new Map([]),
});
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("reuses cached result when threshold increases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
distanceSpy.mockRestore();
});
it("does not reuse cache when threshold decreases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(105, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 6,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("invalidates cache when element version changes", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
const movedElement = {
...element,
version: element.version + 1,
versionNonce: element.versionNonce + 1,
};
expect(
hitElementItself({
point,
element: movedElement,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("override does not affect caching", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(50, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
overrideShouldTestInside: true,
}),
).toBe(true);
});
});
+1 -81
View File
@@ -1,4 +1,4 @@
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable"; import { getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => { describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => { it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -151,83 +151,3 @@ describe("YouTube timestamp parsing", () => {
} }
}); });
}); });
describe("Google Drive video embedding", () => {
it.each([
{
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(expectedLink);
}
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
});
it("should preserve resourcekey when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
);
}
});
it("should preserve timestamp when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
);
}
});
it("should preserve resourcekey and timestamp together", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
);
}
});
it("should validate Google Drive domain by default", () => {
expect(
embeddableURLValidator(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
undefined,
),
).toBe(true);
});
});
@@ -218,7 +218,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
[ [
@@ -378,7 +378,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -419,7 +419,7 @@ describe("Test Linear Elements", () => {
fireEvent.click(screen.getByTitle("Round")); fireEvent.click(screen.getByTitle("Round"));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`10`, `9`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -480,7 +480,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -548,7 +548,7 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
@@ -599,7 +599,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -640,7 +640,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -688,7 +688,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
@@ -746,7 +746,7 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -844,7 +844,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id); expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(404); expect(arrow.width).toBeCloseTo(399);
expect(rect.x).toBe(400); expect(rect.x).toBe(400);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect( expect(
@@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204); expect(arrow.width).toBeCloseTo(199);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+2 -2
View File
@@ -1350,8 +1350,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY); expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561); expect(boundArrow.points[1][0]).toBeCloseTo(59.7979);
expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356); expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2, boundArrow.x + boundArrow.points[1][0] / 2,
@@ -58,7 +58,6 @@ const distributeSelectedElements = (
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
distribution, distribution,
appState, appState,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
+1 -5
View File
@@ -271,11 +271,7 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
} = await loadFromJSON( } = await loadFromJSON(appState, elements);
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
+5 -11
View File
@@ -82,10 +82,7 @@ export const actionFinalize = register<FormData>({
app.scene, app.scene,
); );
if ( if (isBindingElement(element)) {
isBindingElement(element) &&
!appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
const newArrow = !!appState.newElement; const newArrow = !!appState.newElement;
const selectedPointsIndices = const selectedPointsIndices =
@@ -98,21 +95,19 @@ export const actionFinalize = register<FormData>({
map.set(index, { map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords( point: LinearElementEditor.pointFromAbsoluteCoords(
element, element,
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap, elementsMap,
), ),
}); });
return map; return map;
}, new Map()) ?? new Map(); }, new Map()) ?? new Map();
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x, sceneCoords.x,
sceneCoords.y - linearElementEditor.pointerOffset.y, sceneCoords.y,
scene, scene,
appState, appState,
{ {
@@ -175,7 +170,6 @@ export const actionFinalize = register<FormData>({
...linearElementEditor.initialState, ...linearElementEditor.initialState,
lastClickedPoint: -1, lastClickedPoint: -1,
}, },
pointerOffset: { x: 0, y: 0 },
}, },
selectionElement: null, selectionElement: null,
suggestedBinding: null, suggestedBinding: null,
@@ -6,23 +6,11 @@ import {
FONT_FAMILY, FONT_FAMILY,
STROKE_WIDTH, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui"; import { UI } from "../tests/helpers/ui";
import { act, render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
import {
actionChangeBackgroundColor,
actionChangeRoundness,
actionChangeStrokeWidth,
} from "./actionProperties";
const { h } = window;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
@@ -121,21 +109,6 @@ describe("element locking", () => {
expect(crossHatchButton).toBe(null); expect(crossHatchButton).toBe(null);
}); });
it("should show background color picker for selected frame", () => {
const frame = API.createElement({
type: "frame",
});
API.setElements([frame]);
API.setSelectedElements([frame]);
expect(
queryByTestId(
document.body,
`color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`,
),
).not.toBe(null);
});
it("should highlight common stroke width of selected elements", () => { it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -196,77 +169,5 @@ describe("element locking", () => {
"active", "active",
); );
}); });
it("should not update text background when changing background in mixed frame selection", () => {
const frame = API.createElement({
type: "frame",
schemaState: { tracks: {} },
});
const text = API.createElement({
type: "text",
backgroundColor: COLOR_PALETTE.transparent,
});
API.setElements([text, frame]);
API.setSelectedElements([text, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeBackgroundColor, "ui", {
viewBackgroundColor: h.state.viewBackgroundColor,
currentItemBackgroundColor: "#ffc9c9",
});
});
expect(API.getElement(frame).backgroundColor).toBe("#ffc9c9");
expect(API.getElement(text).backgroundColor).toBe(
COLOR_PALETTE.transparent,
);
expect(
API.getElement(frame).schemaState.tracks[CORE_FRAME_SCHEMA_TRACK],
).toBe(CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]);
});
it("should not update frame stroke width when changing stroke width in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
const originalFrameStrokeWidth = API.getElement(frame).strokeWidth;
act(() => {
h.app.actionManager.executeAction(
actionChangeStrokeWidth,
"ui",
STROKE_WIDTH.extraBold,
);
});
expect(API.getElement(rect).strokeWidth).toBe(STROKE_WIDTH.extraBold);
expect(API.getElement(frame).strokeWidth).toBe(originalFrameStrokeWidth);
});
it("should not update frame roundness when changing roundness in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
roundness: null,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeRoundness, "ui", "round");
});
expect(API.getElement(rect).roundness).not.toBe(null);
expect(API.getElement(frame).roundness).toBe(null);
});
}); });
}); });
@@ -45,7 +45,6 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
@@ -53,13 +52,7 @@ import {
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import { hasStrokeColor } from "@excalidraw/element";
canChangeRoundness,
hasBackground,
hasStrokeColor,
hasStrokeStyle,
hasStrokeWidth,
} from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@@ -416,18 +409,11 @@ export const actionChangeBackgroundColor = register<
return el; return el;
}); });
} else { } else {
nextElements = changeProperty(elements, appState, (el) => { nextElements = changeProperty(elements, appState, (el) =>
if (isFrameElement(el)) { newElementWith(el, {
return newElementWith(el, { backgroundColor: value.currentItemBackgroundColor,
backgroundColor: value.currentItemBackgroundColor, }),
}); );
}
return hasBackground(el.type)
? newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
})
: el;
});
} }
return { return {
@@ -458,12 +444,7 @@ export const actionChangeBackgroundColor = register<
(element) => element.backgroundColor, (element) => element.backgroundColor,
true, true,
(hasSelection) => (hasSelection) =>
!hasSelection !hasSelection ? appState.currentItemBackgroundColor : null,
? appState.activeTool.type === "frame"
? // background default shouldn't apply to new frames
"transparent"
: appState.currentItemBackgroundColor
: null,
)} )}
onChange={(color) => onChange={(color) =>
updateData({ currentItemBackgroundColor: color }) updateData({ currentItemBackgroundColor: color })
@@ -490,13 +471,11 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
})`, })`,
); );
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasBackground(el.type) newElementWith(el, {
? newElementWith(el, { fillStyle: value,
fillStyle: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -569,13 +548,11 @@ export const actionChangeStrokeWidth = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeWidth(el.type) newElementWith(el, {
? newElementWith(el, { strokeWidth: value,
strokeWidth: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemStrokeWidth: value }, appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -627,14 +604,12 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeStyle(el.type) newElementWith(el, {
? newElementWith(el, { seed: randomInteger(),
seed: randomInteger(), roughness: value,
roughness: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -685,13 +660,11 @@ export const actionChangeStrokeStyle = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeStyle(el.type) newElementWith(el, {
? newElementWith(el, { strokeStyle: value,
strokeStyle: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemStrokeStyle: value }, appState: { ...appState, currentItemStrokeStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -1503,7 +1476,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el) || !canChangeRoundness(el.type)) { if (isElbowArrow(el)) {
return el; return el;
} }
@@ -1582,7 +1555,7 @@ const getArrowheadOptions = (flip: boolean) => {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />, icon: ArrowheadNoneIcon,
}, },
{ {
value: "arrow", value: "arrow",
@@ -1710,8 +1683,7 @@ export const actionChangeArrowhead = register<{
? element.startArrowhead ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => appState.currentItemStartArrowhead,
hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
@@ -1728,8 +1700,7 @@ export const actionChangeArrowhead = register<{
? element.endArrowhead ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => appState.currentItemEndArrowhead,
hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
+1 -3
View File
@@ -55,8 +55,7 @@ export type ShortcutName =
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette" | "commandPalette"
| "searchMenu" | "searchMenu";
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -118,7 +117,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [], wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+3 -71
View File
@@ -54,13 +54,7 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([ expect(clipboardData.elements).toEqual([rect]);
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
}); });
it("should parse valid excalidraw JSON if inside text/html", async () => { it("should parse valid excalidraw JSON if inside text/html", async () => {
@@ -79,13 +73,7 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([ expect(clipboardData.elements).toEqual([rect]);
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
@@ -97,66 +85,10 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([ expect(clipboardData.elements).toEqual([rect]);
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
}); });
it("should preserve per-element schema on clipboard payload", async () => {
const rect = API.createElement({ type: "rectangle" });
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({ elements: [rect], files: null }),
);
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).toEqual(
expect.objectContaining({
id: rect.id,
schemaState: rect.schemaState,
}),
);
});
it("should not upcast legacy elements to latest schema on clipboard serialize", async () => {
const rect = API.createElement({ type: "rectangle" });
const legacyRect = { ...(rect as any) };
delete legacyRect.schemaState;
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({
elements: [legacyRect as typeof rect],
files: null,
}),
);
expect(clipboardPayload.elements[0]).not.toHaveProperty("schemaState");
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).not.toHaveProperty("schemaState");
});
it("should parse <image> `src` urls out of text/html", async () => { it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+26 -73
View File
@@ -79,10 +79,7 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = ( const clipboardContainsElements = (
contents: any, contents: any,
): contents is { ): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
elements: ExcalidrawElement[];
files?: BinaryFiles;
} => {
if ( if (
[ [
EXPORT_DATA_TYPES.excalidraw, EXPORT_DATA_TYPES.excalidraw,
@@ -207,13 +204,8 @@ export const copyToClipboard = async (
/** supply if available to make the operation more certain to succeed */ /** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
const json = serializeAsClipboardJSON({ elements, files });
await copyTextToSystemClipboard( await copyTextToSystemClipboard(
{ serializeAsClipboardJSON({ elements, files }),
[MIME_TYPES.excalidrawClipboard]: json,
[MIME_TYPES.text]: json,
},
clipboardEvent, clipboardEvent,
); );
}; };
@@ -409,7 +401,7 @@ export type ParsedDataTransferFile = Extract<
{ kind: "file" } { kind: "file" }
>; >;
export type ParsedDataTranferList = ParsedDataTransferItem[] & { type ParsedDataTranferList = ParsedDataTransferItem[] & {
/** /**
* Only allows filtering by known `string` data types, since `file` * Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files) * types can have multiple items of the same type (e.g. multiple image files)
@@ -460,29 +452,6 @@ const getDataTransferFiles = function (
); );
}; };
/** @returns list of MIME types, synchronously */
export const parseDataTransferEventMimeTypes = (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Set<string> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const types: Set<string> = new Set();
for (const item of Array.from(items || [])) {
if (!types.has(item.type)) {
types.add(item.type);
}
}
return types;
};
export const parseDataTransferEvent = async ( export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>, event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => { ): Promise<ParsedDataTranferList> => {
@@ -491,7 +460,8 @@ export const parseDataTransferEvent = async (
if (isClipboardEvent(event)) { if (isClipboardEvent(event)) {
items = event.clipboardData?.items; items = event.clipboardData?.items;
} else { } else {
items = event.dataTransfer?.items; const dragEvent = event;
items = dragEvent.dataTransfer?.items;
} }
const dataItems = ( const dataItems = (
@@ -597,7 +567,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// ClipboardItem constructor, but throws on an unrelated MIME type error. // ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable. // So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new window.ClipboardItem({
[MIME_TYPES.png]: blob, [MIME_TYPES.png]: blob,
}), }),
]); ]);
@@ -606,7 +576,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// with resolution value instead // with resolution value instead
if (isPromiseLike(blob)) { if (isPromiseLike(blob)) {
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new window.ClipboardItem({
[MIME_TYPES.png]: await blob, [MIME_TYPES.png]: await blob,
}), }),
]); ]);
@@ -616,27 +586,28 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
} }
}; };
export const copyTextToSystemClipboard = async < export const copyTextToSystemClipboard = async (
MimeType extends ValueOf<typeof STRING_MIME_TYPES>, text: string | null,
>(
text: string | { [K in MimeType]: string } | null,
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
text = text || ""; // (1) first try using Async Clipboard API
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(text || "");
return;
} catch (error: any) {
console.error(error);
}
}
const entries = Object.entries( // (2) if fails and we have access to ClipboardEvent, use plain old setData()
typeof text === "string" ? { [MIME_TYPES.text]: text } : text,
);
// (1) if we have clipboardEvent, try using it first as it's the most
// versatile
try { try {
if (clipboardEvent) { if (clipboardEvent) {
for (const [mimeType, value] of entries) { clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
clipboardEvent.clipboardData?.setData(mimeType, value); if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
if (clipboardEvent.clipboardData?.getData(mimeType) !== value) { throw new Error("Failed to setData on clipboardEvent");
throw new Error("Failed to setData on clipboardEvent");
}
} }
return; return;
} }
@@ -644,26 +615,8 @@ export const copyTextToSystemClipboard = async <
console.error(error); console.error(error);
} }
const plainTextEntry = entries.find( // (3) if that fails, use document.execCommand
([mimeType]) => mimeType === MIME_TYPES.text, if (!copyTextViaExecCommand(text)) {
);
// (2) if we don't have access to clipboardEvent, or that fails,
// at least try setting text/plain via navigator.clipboard.writeText
// (navigator.clipboard.write doesn't work with non-standard mime types)
if (probablySupportsClipboardWriteText && plainTextEntry) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(plainTextEntry[1]);
return;
} catch (error: any) {
console.error(error);
}
}
// (3) if previous fails, use document.execCommand
if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
throw new Error("Error copying to clipboard."); throw new Error("Error copying to clipboard.");
} }
}; };
+3 -7
View File
@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import { import {
CLASSES, CLASSES,
@@ -20,7 +20,6 @@ import {
isArrowElement, isArrowElement,
hasStrokeColor, hasStrokeColor,
toolIsArrow, toolIsArrow,
isFrameElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { import type {
@@ -130,11 +129,8 @@ export const canChangeBackgroundColor = (
targetElements: ExcalidrawElement[], targetElements: ExcalidrawElement[],
) => { ) => {
return ( return (
// frame tool shouldn't allow to set background until frame is created
hasBackground(appState.activeTool.type) || hasBackground(appState.activeTool.type) ||
targetElements.some( targetElements.some((element) => hasBackground(element.type))
(element) => hasBackground(element.type) || isFrameElement(element),
)
); );
}; };
@@ -1267,9 +1263,9 @@ export const ShapesSwitcher = ({
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
+154 -396
View File
@@ -11,6 +11,7 @@ import {
pointDistance, pointDistance,
vector, vector,
pointRotateRads, pointRotateRads,
vectorScale,
vectorFromPoint, vectorFromPoint,
vectorSubtract, vectorSubtract,
vectorDot, vectorDot,
@@ -46,6 +47,7 @@ import {
TAP_TWICE_TIMEOUT, TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD, TEXT_TO_CENTER_SNAP_THRESHOLD,
THEME, THEME,
THEME_FILTER,
TOUCH_CTX_MENU_TIMEOUT, TOUCH_CTX_MENU_TIMEOUT,
VERTICAL_ALIGN, VERTICAL_ALIGN,
YOUTUBE_STATES, YOUTUBE_STATES,
@@ -87,7 +89,6 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
applyDarkModeFilter,
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
randomInteger, randomInteger,
CLASSES, CLASSES,
@@ -249,14 +250,6 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
convertToExcalidrawElements, convertToExcalidrawElements,
type ExcalidrawElementSkeleton, type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
isFrameElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -353,8 +346,7 @@ import {
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restoreAppState, restoreElements } from "../data/restore"; import { restore, restoreElements } from "../data/restore";
import { createSchemaMigrationRegistry } from "../data/schema";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { History } from "../history"; import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -444,7 +436,10 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { isPointHittingLink } from "./hyperlink/helpers"; import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
@@ -461,7 +456,6 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { SchemaMigrationRegistry } from "../data/schema";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@@ -608,7 +602,6 @@ class App extends React.Component<AppProps, AppState> {
public fonts: Fonts; public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[]; public visibleElements: readonly NonDeletedExcalidrawElement[];
private schemaMigrationRegistry: SchemaMigrationRegistry;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
@@ -650,10 +643,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
/** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler(); animationFrameHandler = new AnimationFrameHandler();
@@ -725,9 +715,6 @@ class App extends React.Component<AppProps, AppState> {
this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface);
this.id = nanoid(); this.id = nanoid();
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
props.schemaPlugins,
);
this.library = new Library(this); this.library = new Library(this);
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
@@ -774,7 +761,6 @@ class App extends React.Component<AppProps, AppState> {
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface, getEditorInterface: () => this.editorInterface,
getSchemaMigrationRegistry: this.getSchemaMigrationRegistry,
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb), onChange: (cb) => this.onChangeEmitter.on(cb),
@@ -1215,99 +1201,12 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id); return this.iFrameRefs.get(element.id);
} }
private handleIframeLikeElementHover = ({ private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if ( if (
hitElement && this.state.activeEmbeddable?.element === element &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active" this.state.activeEmbeddable?.state === "active"
) { ) {
return true; return;
} }
// The delay serves two purposes // The delay serves two purposes
@@ -1318,34 +1217,31 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode // in fullscreen mode
setTimeout(() => { setTimeout(() => {
this.setState({ this.setState({
activeEmbeddable: { element: iframeLikeElement, state: "active" }, activeEmbeddable: { element, state: "active" },
selectedElementIds: { [iframeLikeElement.id]: true }, selectedElementIds: { [element.id]: true },
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
}); });
}, 100); }, 100);
if (isIframeElement(iframeLikeElement)) { if (isIframeElement(element)) {
return true; return;
} }
const iframe = this.getHTMLIFrameElement(iframeLikeElement); const iframe = this.getHTMLIFrameElement(element);
if (!iframe?.contentWindow) { if (!iframe?.contentWindow) {
return true; return;
} }
if (iframe.src.includes("youtube")) { if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); const state = YOUTUBE_VIDEO_STATES.get(element.id);
if (!state) { if (!state) {
YOUTUBE_VIDEO_STATES.set( YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
event: "listening", event: "listening",
id: iframeLikeElement.id, id: element.id,
}), }),
"*", "*",
); );
@@ -1382,8 +1278,6 @@ class App extends React.Component<AppProps, AppState> {
"*", "*",
); );
} }
return true;
} }
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
@@ -1876,9 +1770,8 @@ class App extends React.Component<AppProps, AppState> {
} }
}} }}
style={{ style={{
background: isDarkTheme background: this.state.viewBackgroundColor,
? applyDarkModeFilter(this.state.viewBackgroundColor) filter: isDarkTheme ? THEME_FILTER : "none",
: this.state.viewBackgroundColor,
zIndex: 2, zIndex: 2,
border: "none", border: "none",
display: "block", display: "block",
@@ -1888,9 +1781,7 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: "Assistant", fontFamily: "Assistant",
fontSize: `${FRAME_STYLE.nameFontSize}px`, fontSize: `${FRAME_STYLE.nameFontSize}px`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`, transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: isDarkTheme color: "var(--color-gray-80)",
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
overflow: "hidden", overflow: "hidden",
maxWidth: `${ maxWidth: `${
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
@@ -2225,7 +2116,6 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: this.elementsPendingErasure, elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes: pendingFlowchartNodes:
this.flowChartCreator.pendingNodes, this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}} }}
/> />
{this.state.newElement && ( {this.state.newElement && (
@@ -2246,7 +2136,6 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: elementsPendingErasure:
this.elementsPendingErasure, this.elementsPendingErasure,
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme: this.state.theme,
}} }}
/> />
)} )}
@@ -2327,10 +2216,6 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public getSchemaMigrationRegistry = () => {
return this.schemaMigrationRegistry;
};
public onInsertElements = (elements: readonly ExcalidrawElement[]) => { public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
@@ -2816,48 +2701,46 @@ class App extends React.Component<AppProps, AppState> {
}, },
}; };
} }
const restoredElements = restoreElements(initialData?.elements, null, { const scene = restore(initialData, null, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
let restoredAppState = restoreAppState(initialData?.appState, null); const activeTool = scene.appState.activeTool;
const activeTool = restoredAppState.activeTool;
if (!restoredAppState.preferredSelectionTool.initialized) { if (!scene.appState.preferredSelectionTool.initialized) {
restoredAppState.preferredSelectionTool = { scene.appState.preferredSelectionTool = {
type: type:
this.editorInterface.formFactor === "phone" ? "lasso" : "selection", this.editorInterface.formFactor === "phone" ? "lasso" : "selection",
initialized: true, initialized: true,
}; };
} }
restoredAppState = { scene.appState = {
...restoredAppState, ...scene.appState,
theme: this.props.theme || restoredAppState.theme, theme: this.props.theme || scene.appState.theme,
// we're falling back to current (pre-init) state when deciding // we're falling back to current (pre-init) state when deciding
// whether to open the library, to handle a case where we // whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app // update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library) // with a library install link, which should auto-open the library)
openSidebar: restoredAppState?.openSidebar || this.state.openSidebar, openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool: activeTool:
activeTool.type === "image" || activeTool.type === "image" ||
activeTool.type === "lasso" || activeTool.type === "lasso" ||
activeTool.type === "selection" activeTool.type === "selection"
? { ? {
...activeTool, ...activeTool,
type: restoredAppState.preferredSelectionTool.type, type: scene.appState.preferredSelectionTool.type,
} }
: restoredAppState.activeTool, : scene.appState.activeTool,
isLoading: false, isLoading: false,
toast: this.state.toast, toast: this.state.toast,
}; };
if (initialData?.scrollToContent) { if (initialData?.scrollToContent) {
restoredAppState = { scene.appState = {
...restoredAppState, ...scene.appState,
...calculateScrollCenter(restoredElements, { ...calculateScrollCenter(scene.elements, {
...restoredAppState, ...scene.appState,
width: this.state.width, width: this.state.width,
height: this.state.height, height: this.state.height,
offsetTop: this.state.offsetTop, offsetTop: this.state.offsetTop,
@@ -2869,9 +2752,7 @@ class App extends React.Component<AppProps, AppState> {
this.resetStore(); this.resetStore();
this.resetHistory(); this.resetHistory();
this.syncActionResult({ this.syncActionResult({
elements: restoredElements, ...scene,
appState: restoredAppState,
files: initialData?.files,
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
@@ -2891,7 +2772,7 @@ class App extends React.Component<AppProps, AppState> {
private getFormFactor = (editorWidth: number, editorHeight: number) => { private getFormFactor = (editorWidth: number, editorHeight: number) => {
return ( return (
this.props.UIOptions.getFormFactor?.(editorWidth, editorHeight) ?? this.props.UIOptions.formFactor ??
getFormFactor(editorWidth, editorHeight) getFormFactor(editorWidth, editorHeight)
); );
}; };
@@ -2915,7 +2796,10 @@ class App extends React.Component<AppProps, AppState> {
? this.props.UIOptions.dockedSidebarBreakpoint ? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH; : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
const nextEditorInterface = updateObject(this.editorInterface, { const nextEditorInterface = updateObject(this.editorInterface, {
desktopUIMode: storedDesktopUIMode ?? this.editorInterface.desktopUIMode, desktopUIMode:
this.props.UIOptions.desktopUIMode ??
storedDesktopUIMode ??
this.editorInterface.desktopUIMode,
formFactor: this.getFormFactor(editorWidth, editorHeight), formFactor: this.getFormFactor(editorWidth, editorHeight),
userAgent: userAgentDescriptor, userAgent: userAgentDescriptor,
canFitSidebar: editorWidth > sidebarBreakpoint, canFitSidebar: editorWidth > sidebarBreakpoint,
@@ -3231,12 +3115,6 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (prevProps.schemaPlugins !== this.props.schemaPlugins) {
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
this.props.schemaPlugins,
);
}
this.updateEmbeddables(); this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -3300,7 +3178,6 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setEraserCursor(this.interactiveCanvas, this.state.theme); setEraserCursor(this.interactiveCanvas, this.state.theme);
} }
// Hide hyperlink popup if shown when element type is not selection // Hide hyperlink popup if shown when element type is not selection
if ( if (
prevState.activeTool.type === "selection" && prevState.activeTool.type === "selection" &&
@@ -3740,7 +3617,6 @@ class App extends React.Component<AppProps, AppState> {
}) => { }) => {
const elements = restoreElements(opts.elements, null, { const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -4852,12 +4728,8 @@ class App extends React.Component<AppProps, AppState> {
} }
// Handle Alt key for bind mode // Handle Alt key for bind mode
if (event.key === KEYS.ALT) { if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) {
if (getFeatureFlag("COMPLEX_BINDINGS")) { this.handleSkipBindMode();
this.handleSkipBindMode();
} else {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
} }
if (this.actionManager.handleKeyDown(event)) { if (this.actionManager.handleKeyDown(event)) {
@@ -4873,11 +4745,7 @@ class App extends React.Component<AppProps, AppState> {
this.resetDelayedBindMode(); this.resetDelayedBindMode();
} }
flushSync(() => { this.setState({ isBindingEnabled: false });
this.setState({ isBindingEnabled: false });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
@@ -5057,8 +4925,6 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
event.key === KEYS.G && event.key === KEYS.G &&
(hasBackground(this.state.activeTool.type) || (hasBackground(this.state.activeTool.type) ||
this.state.activeTool.type === "frame" ||
selectedElements.some((element) => isFrameElement(element)) ||
selectedElements.some((element) => hasBackground(element.type))) selectedElements.some((element) => hasBackground(element.type)))
) { ) {
this.setState({ openPopup: "elementBackground" }); this.setState({ openPopup: "elementBackground" });
@@ -5152,11 +5018,6 @@ class App extends React.Component<AppProps, AppState> {
} }
isHoldingSpace = false; isHoldingSpace = false;
} }
if (event.key === KEYS.ALT) {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if ( if (
(event.key === KEYS.ALT && this.state.bindMode === "skip") || (event.key === KEYS.ALT && this.state.bindMode === "skip") ||
(!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state))
@@ -5167,7 +5028,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
// Restart the timer if we're creating/editing a linear element and hovering over an element // Restart the timer if we're creating/editing a linear element and hovering over an element
if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) { if (this.lastPointerMoveEvent) {
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ {
clientX: this.lastPointerMoveEvent.clientX, clientX: this.lastPointerMoveEvent.clientX,
@@ -5188,18 +5049,14 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
if (isBindingElement(element)) { if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleDelayedBindModeChange(element, hoveredElement); this.handleDelayedBindModeChange(element, hoveredElement);
} }
} }
} }
} }
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
flushSync(() => { this.setState({ isBindingEnabled: true });
this.setState({ isBindingEnabled: true });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
bindOrUnbindBindingElements( bindOrUnbindBindingElements(
@@ -6512,28 +6369,15 @@ class App extends React.Component<AppProps, AppState> {
// and point // and point
const { newElement } = this.state; const { newElement } = this.state;
if (!newElement && isBindingEnabled(this.state)) { if (!newElement && isBindingEnabled(this.state)) {
const globalPoint = pointFrom<GlobalPoint>(
scenePointerX,
scenePointerY,
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
globalPoint, pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
elementsMap, this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), (el) => maxBindingDistance_simple(this.state.zoom),
); );
if (hoveredElement) { if (hoveredElement) {
this.setState({ this.setState({
suggestedBinding: { suggestedBinding: hoveredElement,
element: hoveredElement,
midPoint: getSnapOutlineMidPoint(
globalPoint,
hoveredElement,
elementsMap,
this.state.zoom,
),
},
}); });
} else if (this.state.suggestedBinding) { } else if (this.state.suggestedBinding) {
this.setState({ this.setState({
@@ -6560,7 +6404,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), (el) => maxBindingDistance_simple(this.state.zoom),
); );
if (hoveredElement) { if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
@@ -6714,33 +6558,26 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), (el) => maxBindingDistance_simple(this.state.zoom),
); );
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY); if (
const elementsMap = this.scene.getNonDeletedElementsMap(); hit &&
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { !isPointInElement(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
this.scene.getNonDeletedElementsMap(),
)
) {
this.setState({ this.setState({
suggestedBinding: { suggestedBinding: hit,
element: hit,
midPoint: getSnapOutlineMidPoint(
scenePointer,
hit,
elementsMap,
this.state.zoom,
),
},
}); });
} }
} }
const isPressingAnyButton = Boolean(event.buttons); const hasDeselectedButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if ( if (
isPressingAnyButton || hasDeselectedButton ||
// checking against laser so that if you mouseover with a laser tool (this.state.activeTool.type !== "selection" &&
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
@@ -6860,14 +6697,6 @@ class App extends React.Component<AppProps, AppState> {
); );
} else { } else {
hideHyperlinkToolip(); hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if ( if (
hitElement && hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) && (hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6900,15 +6729,24 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked !hitElement?.locked
) { ) {
if ( if (
!this.handleIframeLikeElementHover({ hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement, hitElement,
scenePointer, event,
moveEvent: event, scenePointerX,
}) && scenePointerY,
(!hitElement || )
// Elbow arrows can only be moved when unconnected ) {
!isElbowArrow(hitElement) || setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
!(hitElement.startBinding || hitElement.endBinding)) this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) { ) {
if ( if (
this.state.activeTool.type !== "lasso" || this.state.activeTool.type !== "lasso" ||
@@ -6916,6 +6754,9 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
} }
} }
} else { } else {
@@ -7076,37 +6917,6 @@ class App extends React.Component<AppProps, AppState> {
}, },
}); });
} }
// Check for focus point hover
let hoveredFocusPointBinding: "start" | "end" | null = null;
const arrow = element as any;
if (arrow.startBinding || arrow.endBinding) {
hoveredFocusPointBinding = handleFocusPointHover(
element as ExcalidrawArrowElement,
scenePointerX,
scenePointerY,
this.scene,
this.state,
);
}
if (
this.state.selectedLinearElement.hoveredFocusPointBinding !==
hoveredFocusPointBinding
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
hoveredFocusPointBinding,
},
});
}
// Set cursor to pointer when hovering over a focus point
if (hoveredFocusPointBinding) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} }
@@ -7567,9 +7377,26 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
}; };
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.handleIframeLikeCenterClick()) { if (this.editorInterface.formFactor === "phone" && clicklength < 300) {
return; const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
} }
if (this.editorInterface.isTouchScreen) { if (this.editorInterface.isTouchScreen) {
@@ -7590,7 +7417,20 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
this.redirectToLink(event, this.editorInterface.isTouchScreen); if (
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
this.setState({ this.setState({
activeEmbeddable: null, activeEmbeddable: null,
@@ -7998,37 +7838,6 @@ class App extends React.Component<AppProps, AppState> {
if (ret.didAddPoint) { if (ret.didAddPoint) {
return true; return true;
} }
// Also check at current pointer position if focus point is being hovered
// (in case we're clicking directly without a prior move event)
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
if (arrow && isBindingElement(arrow)) {
const { hitFocusPoint, pointerOffset } =
handleFocusPointPointerDown(
arrow,
pointerDownState,
elementsMap,
this.state,
);
// If focus point is hit, update state and prevent element selection
if (hitFocusPoint) {
this.setState({
selectedLinearElement: {
...linearElementEditor,
hoveredFocusPointBinding: hitFocusPoint,
draggedFocusPointBinding: hitFocusPoint,
pointerOffset,
},
});
return false;
}
}
} }
const allHitElements = this.getElementsAtPosition( const allHitElements = this.getElementsAtPosition(
@@ -8841,7 +8650,6 @@ class App extends React.Component<AppProps, AppState> {
selectedPointsIndices: [endIdx], selectedPointsIndices: [endIdx],
initialState: { initialState: {
...linearElementEditor.initialState, ...linearElementEditor.initialState,
arrowStartIsInside: event.altKey,
lastClickedPoint: endIdx, lastClickedPoint: endIdx,
origin: pointFrom<GlobalPoint>( origin: pointFrom<GlobalPoint>(
pointerDownState.origin.x, pointerDownState.origin.x,
@@ -8860,18 +8668,7 @@ class App extends React.Component<AppProps, AppState> {
bindMode: "orbit", bindMode: "orbit",
newElement: element, newElement: element,
startBoundElement: boundElement, startBoundElement: boundElement,
suggestedBinding: suggestedBinding: boundElement || null,
boundElement && isBindingElement(element)
? {
element: boundElement,
midPoint: getSnapOutlineMidPoint(
point,
boundElement,
elementsMap,
this.state.zoom,
),
}
: null,
selectedElementIds: nextSelectedElementIds, selectedElementIds: nextSelectedElementIds,
selectedLinearElement: linearElementEditor, selectedLinearElement: linearElementEditor,
}; };
@@ -9133,8 +8930,8 @@ class App extends React.Component<AppProps, AppState> {
} }
const lastPointerCoords = const lastPointerCoords =
this.previousPointerMoveCoords ?? pointerDownState.origin; this.lastPointerMoveCoords ?? pointerDownState.origin;
this.previousPointerMoveCoords = pointerCoords; this.lastPointerMoveCoords = pointerCoords;
// We need to initialize dragOffsetXY only after we've updated // We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
@@ -9188,31 +8985,6 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectedLinearElement) { if (this.state.selectedLinearElement) {
const linearElementEditor = this.state.selectedLinearElement; const linearElementEditor = this.state.selectedLinearElement;
// Handle focus point dragging if needed
if (linearElementEditor.draggedFocusPointBinding) {
handleFocusPointDrag(
linearElementEditor,
elementsMap,
pointerCoords,
this.scene,
this.state,
this.getEffectiveGridSize(),
event.altKey,
);
this.setState({
selectedLinearElement: {
...linearElementEditor,
isDragging: false,
selectedPointsIndices: [],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
});
return;
}
if ( if (
LinearElementEditor.shouldAddMidpoint( LinearElementEditor.shouldAddMidpoint(
this.state.selectedLinearElement, this.state.selectedLinearElement,
@@ -9451,21 +9223,14 @@ class App extends React.Component<AppProps, AppState> {
this.imageCache.get(croppingElement.fileId)?.image; this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) { if (image && !(image instanceof Promise)) {
const uncroppedSize = const instantDragOffset = vectorScale(
getUncroppedWidthAndHeight(croppingElement); vector(
const instantDragOffset = vector( pointerCoords.x - lastPointerCoords.x,
pointerCoords.x - lastPointerCoords.x, pointerCoords.y - lastPointerCoords.y,
pointerCoords.y - lastPointerCoords.y, ),
Math.max(this.state.zoom.value, 2),
); );
// to reduce cursor:image drift, we need to take into account
// the canvas image element scaling so we can accurately
// track the pixels on movement
instantDragOffset[0] *=
image.naturalWidth / uncroppedSize.width;
instantDragOffset[1] *=
image.naturalHeight / uncroppedSize.height;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement, croppingElement,
elementsMap, elementsMap,
@@ -9508,13 +9273,13 @@ class App extends React.Component<AppProps, AppState> {
const nextCrop = { const nextCrop = {
...crop, ...crop,
x: clamp( x: clamp(
crop.x - crop.x +
offsetVector[0] * Math.sign(croppingElement.scale[0]), offsetVector[0] * Math.sign(croppingElement.scale[0]),
0, 0,
image.naturalWidth - crop.width, image.naturalWidth - crop.width,
), ),
y: clamp( y: clamp(
crop.y - crop.y +
offsetVector[1] * Math.sign(croppingElement.scale[1]), offsetVector[1] * Math.sign(croppingElement.scale[1]),
0, 0,
image.naturalHeight - crop.height, image.naturalHeight - crop.height,
@@ -10007,7 +9772,6 @@ class App extends React.Component<AppProps, AppState> {
// just in case, tool changes mid drag, always clean up // just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.previousPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null); SnapCache.setVisibleGaps(null);
@@ -10089,14 +9853,12 @@ class App extends React.Component<AppProps, AppState> {
// and sets binding element // and sets binding element
if ( if (
this.state.selectedLinearElement?.isEditing && this.state.selectedLinearElement?.isEditing &&
!this.state.newElement && !this.state.newElement
this.state.selectedLinearElement.draggedFocusPointBinding === null
) { ) {
if ( if (
!pointerDownState.boxSelection.hasOccurred && !pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId && this.state.selectedLinearElement.elementId
this.state.selectedLinearElement.draggedFocusPointBinding === null
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
} else { } else {
@@ -10132,18 +9894,7 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (this.state.selectedLinearElement.draggedFocusPointBinding) { if (
handleFocusPointPointerUp(
this.state.selectedLinearElement,
this.scene,
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
draggedFocusPointBinding: null,
},
});
} else if (
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId
) { ) {
@@ -10153,12 +9904,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ selectedLinearElement: null }); this.setState({ selectedLinearElement: null });
} }
} else if (this.state.selectedLinearElement.isDragging) { } else if (this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
},
});
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
@@ -10301,7 +10046,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (isLinearElement(newElement)) { if (isBindingElement(newElement, false)) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
@@ -10933,6 +10678,25 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null, suggestedBinding: null,
}); });
} }
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
}); });
} }
@@ -11447,7 +11211,6 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
@@ -11494,11 +11257,7 @@ class App extends React.Component<AppProps, AppState> {
); );
// legacy library dataTransfer format // legacy library dataTransfer format
} else if (excalidrawLibrary_data) { } else if (excalidrawLibrary_data) {
libraryItems = parseLibraryJSON( libraryItems = parseLibraryJSON(excalidrawLibrary_data);
excalidrawLibrary_data,
"unpublished",
this.schemaMigrationRegistry,
);
} }
if (libraryItems?.length) { if (libraryItems?.length) {
libraryItems = libraryItems.map((item) => ({ libraryItems = libraryItems.map((item) => ({
@@ -11568,7 +11327,6 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
elements, elements,
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
} catch (error: any) { } catch (error: any) {
const imageSceneDataError = error instanceof ImageSceneDataError; const imageSceneDataError = error instanceof ImageSceneDataError;
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Avatar { .Avatar {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/theme" as *; @import "../css/theme";
.excalidraw { .excalidraw {
.excalidraw-button { .excalidraw-button {
@@ -1,4 +1,4 @@
@use "../css/theme" as *; @import "../css/theme";
.excalidraw { .excalidraw {
button.standalone { button.standalone {
+3 -3
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module.scss" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Card { .Card {
@@ -19,7 +19,7 @@
padding: 1.4rem; padding: 1.4rem;
border-radius: 50%; border-radius: 50%;
background: var(--card-color); background: var(--card-color);
color: #fff; color: $oc-white;
svg { svg {
width: 2.8rem; width: 2.8rem;
@@ -46,7 +46,7 @@
background-color: var(--card-color-darkest); background-color: var(--card-color-darkest);
} }
.ToolIcon__label { .ToolIcon__label {
color: #fff; color: $oc-white;
} }
.Spinner { .Spinner {
+13 -23
View File
@@ -1,35 +1,25 @@
import OpenColor from "open-color";
import "./Card.scss"; import "./Card.scss";
// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss
const COLOR_MAP = {
primary: {
base: "var(--color-primary)",
darker: "var(--color-primary-darker)",
darkest: "var(--color-primary-darkest)",
},
lime: {
base: "#74b816", // open-color lime[7]
darker: "#66a80f", // open-color lime[8]
darkest: "#5c940d", // open-color lime[9]
},
pink: {
base: "#d6336c", // open-color pink[7]
darker: "#c2255c", // open-color pink[8]
darkest: "#a61e4d", // open-color pink[9]
},
};
export const Card: React.FC<{ export const Card: React.FC<{
color: "primary" | "lime" | "pink"; color: keyof OpenColor | "primary";
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
className="Card" className="Card"
style={{ style={{
["--card-color" as any]: COLOR_MAP[color].base, ["--card-color" as any]:
["--card-color-darker" as any]: COLOR_MAP[color].darker, color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
["--card-color-darkest" as any]: COLOR_MAP[color].darkest, ["--card-color-darker" as any]:
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
}} }}
> >
{children} {children}
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Checkbox { .Checkbox {
@@ -13,7 +12,7 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$color-blue-4}; box-shadow: 0 0 0 2px #{$oc-blue-4};
} }
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
@@ -25,25 +24,25 @@
&:active { &:active {
.Checkbox-box { .Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important; box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
} }
} }
&:hover { &:hover {
.Checkbox-box { .Checkbox-box {
background-color: color.adjust($color-blue-1, $alpha: -0.8); background-color: fade-out($oc-blue-1, 0.8);
} }
} }
&.is-checked { &.is-checked {
.Checkbox-box { .Checkbox-box {
background-color: #{$color-blue-1}; background-color: #{$oc-blue-1};
svg { svg {
display: block; display: block;
} }
} }
&:hover .Checkbox-box { &:hover .Checkbox-box {
background-color: #{$color-blue-2}; background-color: #{$oc-blue-2};
} }
} }
@@ -59,16 +58,16 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 0 2px #{$color-blue-7}; box-shadow: 0 0 0 2px #{$oc-blue-7};
background-color: transparent; background-color: transparent;
border-radius: 4px; border-radius: 4px;
color: #{$color-blue-7}; color: #{$oc-blue-7};
border: 0; border: 0;
&:focus { &:focus {
box-shadow: 0 0 0 3px #{$color-blue-7}; box-shadow: 0 0 0 3px #{$oc-blue-7};
} }
svg { svg {
@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, normalizeInputColor } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut"; import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
@@ -10,23 +10,26 @@ import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
import { getColor } from "./ColorPicker";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface ColorInputProps {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}
export const ColorInput = ({ export const ColorInput = ({
color, color,
onChange, onChange,
label, label,
colorPickerType, colorPickerType,
placeholder, placeholder,
}: { }: ColorInputProps) => {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color); const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom( const [activeSection, setActiveColorPickerSection] = useAtom(
@@ -40,7 +43,7 @@ export const ColorInput = ({
const changeColor = useCallback( const changeColor = useCallback(
(inputValue: string) => { (inputValue: string) => {
const value = inputValue.toLowerCase(); const value = inputValue.toLowerCase();
const color = normalizeInputColor(value); const color = getColor(value);
if (color) { if (color) {
onChange(color); onChange(color);
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../../css/variables.module.scss";
@use "../../css/variables.module" as *;
.excalidraw { .excalidraw {
.focus-visible-none { .focus-visible-none {
@@ -186,8 +185,8 @@
.color-picker { .color-picker {
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px; box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -244,7 +243,7 @@
} }
.color-picker-triangle-shadow { .color-picker-triangle-shadow {
border-color: transparent transparent color.adjust(#000, $alpha: -0.9); border-color: transparent transparent transparentize($oc-black, 0.9);
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: -14px; left: -14px;
@@ -281,7 +280,7 @@
padding: 0.25rem; padding: 0.25rem;
&-title { &-title {
color: $color-gray-6; color: $oc-gray-6;
font-size: 12px; font-size: 12px;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
@@ -320,7 +319,7 @@
.color-picker-transparent { .color-picker-transparent {
border-radius: 4px; border-radius: 4px;
box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset; box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@@ -474,7 +473,7 @@
} }
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #fff; color: $oc-white;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -487,10 +486,10 @@
&.theme--dark { &.theme--dark {
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #000; color: $oc-black;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
@@ -1,11 +1,11 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isColorDark, isTransparent,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -30,7 +30,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -38,6 +38,27 @@ import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
return !!style.color;
};
export const getColor = (color: string): string | null => {
if (isTransparent(color)) {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color
: null;
};
interface ColorPickerProps { interface ColorPickerProps {
type: ColorPickerType; type: ColorPickerType;
/** /**
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { isColorDark } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
interface HotkeyLabelProps { interface HotkeyLabelProps {
color: string; color: string;
@@ -5,9 +5,10 @@ import {
DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS, DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps { interface TopPicksProps {
@@ -96,6 +96,63 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom = export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null); atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (color === "transparent") {
return false;
}
// a string color (white etc) or any other format -> convert to rgb by way
// of creating a DOM node and retrieving the computeStyle
if (!color.startsWith("#")) {
const node = document.createElement("div");
node.style.color = color;
if (node.style.color) {
// making invisible so document doesn't reflow (hopefully).
// display=none works too, but supposedly not in all browsers
node.style.position = "absolute";
node.style.visibility = "hidden";
node.style.width = "0";
node.style.height = "0";
// needs to be in DOM else browser won't compute the style
document.body.appendChild(node);
const computedColor = getComputedStyle(node).color;
document.body.removeChild(node);
// computed style is in rgb() format
const rgb = computedColor
.replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "")
.replace(/\s/g, "")
.split(",");
const r = parseInt(rgb[0]);
const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]);
return calculateContrast(r, g, b) < threshold;
}
// invalid color -> assume it default to black
return true;
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return calculateContrast(r, g, b) < threshold;
};
export type ColorPickerType = export type ColorPickerType =
| "canvasBackground" | "canvasBackground"
| "elementBackground" | "elementBackground"
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
@@ -44,6 +44,7 @@ import { getSelectedElements } from "../../scene";
import { import {
LockedIcon, LockedIcon,
UnlockedIcon, UnlockedIcon,
clockIcon,
searchIcon, searchIcon,
boltIcon, boltIcon,
bucketFillIcon, bucketFillIcon,
@@ -51,7 +52,6 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
brainIconThin, brainIconThin,
LibraryIcon, LibraryIcon,
historyCommandIcon,
} from "../icons"; } from "../icons";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@@ -928,7 +928,7 @@ function CommandPaletteInner({
marginLeft: "6px", marginLeft: "6px",
}} }}
> >
{historyCommandIcon} {clockIcon}
</div> </div>
</div> </div>
<CommandItem <CommandItem
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.confirm-dialog { .confirm-dialog {
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.context-menu-popover { .context-menu-popover {
@@ -9,7 +8,7 @@
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8); box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
padding: 0; padding: 0;
list-style: none; list-style: none;
user-select: none; user-select: none;
@@ -50,7 +49,7 @@
&.dangerous { &.dangerous {
.context-menu-item__label { .context-menu-item__label {
color: $color-red-7; color: $oc-red-7;
} }
} }
@@ -74,7 +73,7 @@
.context-menu-item__label { .context-menu-item__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $color-red-6; background-color: $oc-red-6;
} }
} }
@@ -98,6 +97,6 @@
.context-menu-item-separator { .context-menu-item-separator {
border: none; border: none;
border-top: 1px solid $color-gray-5; border-top: 1px solid $oc-gray-5;
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css//variables.module.scss";
.excalidraw { .excalidraw {
.ConvertElementTypePopup { .ConvertElementTypePopup {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Dialog { .Dialog {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ElementLinkDialog { .ElementLinkDialog {
@@ -59,7 +59,7 @@
} }
.ElementLinkDialog__remove { .ElementLinkDialog__remove {
color: $color-red-9; color: $oc-red-9;
margin-left: 1rem; margin-left: 1rem;
.ToolIcon__icon { .ToolIcon__icon {
@@ -68,7 +68,7 @@
} }
.ToolIcon__icon svg { .ToolIcon__icon svg {
color: $color-red-6; color: $oc-red-6;
} }
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ExportDialog__preview { .ExportDialog__preview {
@@ -112,7 +112,7 @@
font-family: Cascadia; font-family: Cascadia;
font-size: 1.8em; font-size: 1.8em;
color: #fff; color: $oc-white;
&:hover { &:hover {
background-color: var(--button-color-darker); background-color: var(--button-color-darker);
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
@keyframes successStatusAnimation { @keyframes successStatusAnimation {
0% { 0% {
@@ -24,14 +24,6 @@
background-color: var(--back-color); background-color: var(--back-color);
border-color: var(--border-color); border-color: var(--border-color);
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&:hover { &:hover {
transition: all 150ms ease-out; transition: all 150ms ease-out;
} }
@@ -53,7 +45,6 @@
&.ExcButton--status-loading, &.ExcButton--status-loading,
&.ExcButton--status-success { &.ExcButton--status-success {
pointer-events: none; pointer-events: none;
background-color: var(--color-success);
.ExcButton__contents { .ExcButton__contents {
visibility: hidden; visibility: hidden;
@@ -61,19 +52,7 @@
} }
&[disabled] { &[disabled] {
cursor: not-allowed; pointer-events: none;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
} }
&, &,
@@ -287,6 +266,14 @@
} }
} }
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&--size-large { &--size-large {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
@@ -33,7 +33,6 @@ export type FilledButtonProps = {
fullWidth?: boolean; fullWidth?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
disabled?: boolean;
}; };
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>( export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
@@ -49,7 +48,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
fullWidth, fullWidth,
className, className,
status, status,
disabled,
}, },
ref, ref,
) => { ) => {
@@ -96,7 +94,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={disabled || _status === "loading" || _status === "success"} disabled={_status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{_status === "loading" ? ( {_status === "loading" ? (
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.FixedSideContainer { .FixedSideContainer {
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.FontPicker__container { .FontPicker__container {
@@ -1,31 +0,0 @@
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../..";
import { Keyboard } from "../../tests/helpers/ui";
import { act, render } from "../../tests/test-utils";
describe("FontPicker", () => {
it("should be able to open font picker", async () => {
(global as any).ResizeObserver =
(global as any).ResizeObserver ||
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const { queryByTestId } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
Keyboard.keyPress(KEYS.T);
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
expect(fontPickerTrigger).not.toBeNull();
act(() => {
fontPickerTrigger!.click();
});
});
});
@@ -1,4 +1,4 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
@@ -30,12 +30,10 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import { import DropdownMenuItem, {
DropDownMenuItemBadgeType, DropDownMenuItemBadgeType,
DropDownMenuItemBadge, DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem"; } from "../dropdownMenu/DropdownMenuItem";
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import { import {
FontFamilyCodeIcon, FontFamilyCodeIcon,
FontFamilyHeadingIcon, FontFamilyHeadingIcon,
@@ -271,74 +269,43 @@ export const FontPickerList = React.memo(
[filteredFonts, sceneFamilies], [filteredFonts, sceneFamilies],
); );
const FontPickerListItem = ({ const renderFont = (font: FontDescriptor, index: number) => (
font, <DropdownMenuItem
order, key={font.value}
}: { icon={font.icon}
font: FontDescriptor; value={font.value}
order: number; order={index}
}) => { textStyle={{
const ref = useRef<HTMLButtonElement>(null); fontFamily: getFontFamilyString({ fontFamily: font.value }),
const isHovered = font.value === hoveredFont?.value; }}
const isSelected = font.value === selectedFontFamily; hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
useEffect(() => { // allow to tab between search and selected font
if (!isHovered) { tabIndex={font.value === selectedFontFamily ? 0 : -1}
return; onClick={(e) => {
} wrappedOnSelect(Number(e.currentTarget.value));
if (order === 0) { }}
// scroll into the first item differently, so it's visible what is above (i.e. group title) onMouseMove={() => {
ref.current?.scrollIntoView?.({ block: "end" }); if (hoveredFont?.value !== font.value) {
} else { onHover(font.value);
ref.current?.scrollIntoView?.({ block: "nearest" }); }
} }}
}, [isHovered, order]); >
{font.text}
return ( {font.badge && (
<button <DropDownMenuItemBadge type={font.badge.type}>
ref={ref} {font.badge.placeholder}
type="button" </DropDownMenuItemBadge>
value={font.value} )}
className={getDropdownMenuItemClassName("", isSelected, isHovered)} </DropdownMenuItem>
title={font.text} );
// allow to tab between search and selected font
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
<MenuItemContent
icon={font.icon}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
>
{font.text}
</MenuItemContent>
</button>
);
};
const groups = []; const groups = [];
if (sceneFilteredFonts.length) { if (sceneFilteredFonts.length) {
groups.push( groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1"> <DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map((font, index) => ( {sceneFilteredFonts.map(renderFont)}
<FontPickerListItem key={font.value} font={font} order={index} />
))}
</DropdownMenuGroup>, </DropdownMenuGroup>,
); );
} }
@@ -346,13 +313,9 @@ export const FontPickerList = React.memo(
if (availableFilteredFonts.length) { if (availableFilteredFonts.length) {
groups.push( groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2"> <DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) => ( {availableFilteredFonts.map((font, index) =>
<FontPickerListItem renderFont(font, index + sceneFilteredFonts.length),
key={font.value} )}
font={font}
order={index + sceneFilteredFonts.length}
/>
))}
</DropdownMenuGroup>, </DropdownMenuGroup>,
); );
} }
@@ -1,4 +1,4 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common"; import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.HelpDialog { .HelpDialog {
@@ -60,12 +60,11 @@
&__islands-container { &__islands-container {
display: grid; display: grid;
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
} }
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {

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