Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Di e625d5aba3 fix: extend wait time for file loading on mobile devices 2025-08-01 12:42:20 +10:00
167 changed files with 2544 additions and 9447 deletions
+2
View File
@@ -1,3 +1,5 @@
version: "3.8"
services: services:
excalidraw: excalidraw:
build: build:
+8 -10
View File
@@ -20,6 +20,7 @@ import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -119,7 +120,6 @@ import {
LibraryIndexedDBAdapter, LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter, LibraryLocalStorageMigrationAdapter,
LocalData, LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData"; } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -499,6 +499,11 @@ const ExcalidrawWrapper = () => {
} }
}; };
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
@@ -589,6 +594,7 @@ const ExcalidrawWrapper = () => {
visibilityChange, visibilityChange,
false, false,
); );
clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -728,8 +734,6 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom); const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback( const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState], [setShareDialogState],
@@ -904,15 +908,10 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="alertalert--warning"> <div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
</div> </div>
)} )}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && ( {latestShareableLink && (
<ShareableLinkDialog <ShareableLinkDialog
link={latestShareableLink} link={latestShareableLink}
@@ -1143,7 +1142,6 @@ const ExcalidrawWrapper = () => {
ref={debugCanvasRef} ref={debugCanvasRef}
/> />
)} )}
{/* <FreedrawDebugSliders /> */}
</Excalidraw> </Excalidraw>
</div> </div>
); );
+1 -2
View File
@@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
// should be aligned with MAX_ALLOWED_FILE_BYTES export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;
+1 -4
View File
@@ -530,10 +530,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null; return null;
} }
if (existingRoomLinkData) { if (!existingRoomLinkData) {
// when joining existing room, don't merge it with current scene data
this.excalidrawAPI.resetScene();
} else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => { const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") { if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" }); return newElementWith(element, { status: "pending" });
@@ -1,150 +0,0 @@
import { STROKE_OPTIONS, isFreeDrawElement } from "@excalidraw/element";
import { useState, useEffect } from "react";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useExcalidrawElements } from "@excalidraw/excalidraw/components/App";
import { round } from "../../packages/math/src";
export const FreedrawDebugSliders = () => {
const [streamline, setStreamline] = useState<number>(
STROKE_OPTIONS.default.streamline,
);
const [simplify, setSimplify] = useState<number>(
STROKE_OPTIONS.default.simplify,
);
useEffect(() => {
if (!window.h) {
window.h = {} as any;
}
if (!window.h.debugFreedraw) {
window.h.debugFreedraw = {
enabled: true,
...STROKE_OPTIONS.default,
};
}
setStreamline(window.h.debugFreedraw.streamline);
setSimplify(window.h.debugFreedraw.simplify);
}, []);
const handleStreamlineChange = (value: number) => {
setStreamline(value);
if (window.h && window.h.debugFreedraw) {
window.h.debugFreedraw.streamline = value;
}
};
const handleSimplifyChange = (value: number) => {
setSimplify(value);
if (window.h && window.h.debugFreedraw) {
window.h.debugFreedraw.simplify = value;
}
};
const [enabled, setEnabled] = useState<boolean>(
window.h?.debugFreedraw?.enabled ?? true,
);
// counter incrasing each 50ms
const [, setCounter] = useState<number>(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prev) => prev + 1);
}, 50);
return () => clearInterval(interval);
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const elements = useExcalidrawElements();
const appState = useUIAppState();
const newFreedrawElement =
appState.newElement && isFreeDrawElement(appState.newElement)
? appState.newElement
: null;
return (
<div
style={{
position: "absolute",
bottom: "70px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 9999,
padding: "10px",
borderRadius: "8px",
border: "1px solid #ccc",
display: "flex",
flexDirection: "column",
gap: "8px",
fontSize: "12px",
fontFamily: "monospace",
}}
>
{newFreedrawElement && (
<div>
pressures:{" "}
{newFreedrawElement.simulatePressure
? "simulated"
: JSON.stringify(
newFreedrawElement.pressures
.slice(-4)
.map((x) => round(x, 2))
.join(" ") || [],
)}{" "}
({round(window.__lastPressure__ || 0, 2) || "?"})
</div>
)}
<div>
<label>
{" "}
enabled
<br />
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
if (window.h.debugFreedraw) {
window.h.debugFreedraw.enabled = e.target.checked;
setEnabled(e.target.checked);
}
}}
/>
</label>
</div>
<div>
<label>
Streamline: {streamline.toFixed(2)}
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={streamline}
onChange={(e) => handleStreamlineChange(parseFloat(e.target.value))}
style={{ width: "150px" }}
/>
</label>
</div>
<div>
<label>
Simplify: {simplify.toFixed(2)}
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={simplify}
onChange={(e) => handleSimplifyChange(parseFloat(e.target.value))}
style={{ width: "150px" }}
/>
</label>
</div>
</div>
);
};
-17
View File
@@ -27,8 +27,6 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -47,8 +45,6 @@ import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store"); const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager { class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => { await entries(filesStore).then((entries) => {
@@ -73,9 +69,6 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try { try {
const _appState = clearAppStateForLocalStorage(appState); const _appState = clearAppStateForLocalStorage(appState);
@@ -95,22 +88,12 @@ const saveDataStateToLocalStorage = (
JSON.stringify(_appState), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
console.error(error); console.error(error);
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, true);
}
} }
}; };
const isQuotaExceededError = (error: any) => {
return error instanceof DOMException && error.name === "QuotaExceededError";
};
type SavingLockTypes = "collaboration"; type SavingLockTypes = "collaboration";
export class LocalData { export class LocalData {
+1 -3
View File
@@ -259,9 +259,7 @@ export const loadFromFirebase = async (
} }
const storedScene = docSnap.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null, { restoreElements(await decryptElements(storedScene, roomKey), null),
deleteInvisibleElements: true,
}),
); );
if (socket) { if (socket) {
+1 -6
View File
@@ -258,16 +258,11 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ { repairBindings: true, refreshDimensions: false },
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true,
}); });
} }
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw Whiteboard</title> <title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +14,7 @@
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<meta <meta
name="title" name="title"
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw" content="Excalidraw — Collaborative whiteboarding made easy"
/> />
<meta <meta
name="description" name="description"
+3 -11
View File
@@ -58,7 +58,7 @@
} }
} }
.alert { .collab-offline-warning {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 6.5rem; top: 6.5rem;
@@ -69,18 +69,10 @@
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6; z-index: 6;
white-space: pre; white-space: pre;
&--warning {
background-color: var(--color-warning);
color: var(--color-text-warning);
}
&--danger {
background-color: var(--color-danger-dark);
color: var(--color-danger-text);
}
} }
} }
@@ -0,0 +1,34 @@
import { defaultLang } from "@excalidraw/excalidraw/i18n";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
screen,
fireEvent,
waitFor,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
describe("Test LanguageList", () => {
it("rerenders UI on language change", async () => {
await render(<ExcalidrawApp />);
// select rectangle tool to show properties menu
UI.clickTool("rectangle");
// english lang should display `thin` label
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: "de-DE" },
});
// switching to german, `thin` label should no longer exist
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
// reset language
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: defaultLang.code },
});
// switching back to English
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
});
});
+1 -1
View File
@@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
}, },
"isTouchScreen": false, "isTouchScreen": false,
"viewport": { "viewport": {
"isLandscape": true, "isLandscape": false,
"isMobile": true, "isMobile": true,
}, },
} }
+19 -24
View File
@@ -5,18 +5,17 @@ export class BinaryHeap<T> {
sinkDown(idx: number) { sinkDown(idx: number) {
const node = this.content[idx]; const node = this.content[idx];
const nodeScore = this.scoreFunction(node);
while (idx > 0) { while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1; const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN]; const parent = this.content[parentN];
if (nodeScore < this.scoreFunction(parent)) { if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent; this.content[idx] = parent;
idx = parentN; // TODO: Optimize idx = parentN; // TODO: Optimize
} else { } else {
break; break;
} }
} }
this.content[idx] = node;
} }
bubbleUp(idx: number) { bubbleUp(idx: number) {
@@ -25,39 +24,35 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node); const score = this.scoreFunction(node);
while (true) { while (true) {
const child1N = ((idx + 1) << 1) - 1; const child2N = (idx + 1) << 1;
const child2N = child1N + 1; const child1N = child2N - 1;
let smallestIdx = idx; let swap = null;
let smallestScore = score; let child1Score = 0;
// Check left child
if (child1N < length) { if (child1N < length) {
const child1Score = this.scoreFunction(this.content[child1N]); const child1 = this.content[child1N];
if (child1Score < smallestScore) { child1Score = this.scoreFunction(child1);
smallestIdx = child1N; if (child1Score < score) {
smallestScore = child1Score; swap = child1N;
} }
} }
// Check right child
if (child2N < length) { if (child2N < length) {
const child2Score = this.scoreFunction(this.content[child2N]); const child2 = this.content[child2N];
if (child2Score < smallestScore) { const child2Score = this.scoreFunction(child2);
smallestIdx = child2N; if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
} }
} }
if (smallestIdx === idx) { if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break; break;
} }
// Move the smaller child up, continue finding position for node
this.content[idx] = this.content[smallestIdx];
idx = smallestIdx;
} }
// Place node in its final position
this.content[idx] = node;
} }
push(node: T) { push(node: T) {
+7 -41
View File
@@ -18,20 +18,13 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari = export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1; !isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS = export const isIOS =
/iPad|iPhone/i.test(navigator.platform) || /iPad|iPhone/.test(navigator.platform) ||
// iPadOS 13+ // iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document); (navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test // keeping function so it can be mocked in test
export const isBrave = () => export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave"; (navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent,
) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -125,12 +118,10 @@ export const ENV = {
}; };
export const CLASSES = { export const CLASSES = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -261,20 +252,13 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const STRING_MIME_TYPES = { export const MIME_TYPES = {
text: "text/plain", text: "text/plain",
html: "text/html", html: "text/html",
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data // image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml", "excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png", "excalidraw.png": "image/png",
@@ -351,20 +335,10 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints // breakpoints
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// md screen
// mobile: up to 699px export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500; export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -442,9 +416,8 @@ export const ROUGHNESS = {
export const STROKE_WIDTH = { export const STROKE_WIDTH = {
thin: 1, thin: 1,
medium: 2, bold: 2,
bold: 4, extraBold: 4,
extraBold: 8,
} as const; } as const;
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
@@ -460,7 +433,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid", fillStyle: "solid",
strokeWidth: STROKE_WIDTH.medium, strokeWidth: 2,
strokeStyle: "solid", strokeStyle: "solid",
roughness: ROUGHNESS.artist, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
@@ -542,10 +515,3 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
// glass background for mobile action buttons
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
} as const;
+15 -65
View File
@@ -20,8 +20,7 @@ import {
ENV, ENV,
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isAndroid, isDarwin,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@@ -92,8 +91,7 @@ export const isWritableElement = (
(target instanceof HTMLInputElement && (target instanceof HTMLInputElement &&
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password" || target.type === "password"));
target.type === "search"));
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -121,11 +119,6 @@ export const getFontString = ({
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
}; };
/** executes callback in the frame that's after the current one */
export const nextAnimationFrame = async (cb: () => any) => {
requestAnimationFrame(() => requestAnimationFrame(cb));
};
export const debounce = <T extends any[]>( export const debounce = <T extends any[]>(
fn: (...args: T) => void, fn: (...args: T) => void,
timeout: number, timeout: number,
@@ -425,6 +418,19 @@ export const allowFullScreen = () =>
export const exitFullScreen = () => document.exitFullscreen(); export const exitFullScreen = () => document.exitFullscreen();
export const getShortcutKey = (shortcut: string): string => {
shortcut = shortcut
.replace(/\bAlt\b/i, "Alt")
.replace(/\bShift\b/i, "Shift")
.replace(/\b(Enter|Return)\b/i, "Enter");
if (isDarwin) {
return shortcut
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
.replace(/\bAlt\b/i, "Option");
}
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
};
export const viewportCoordsToSceneCoords = ( export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
{ {
@@ -1272,59 +1278,3 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue; return commonValue;
}; };
export const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};
+4 -16
View File
@@ -164,14 +164,9 @@ export class Scene {
return this.frames; return this.frames;
} }
constructor( constructor(elements: ElementsMapOrArray | null = null) {
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) { if (elements) {
this.replaceAllElements(elements, options); this.replaceAllElements(elements);
} }
} }
@@ -268,19 +263,12 @@ export class Scene {
return didChange; return didChange;
} }
replaceAllElements( replaceAllElements(nextElements: ElementsMapOrArray) {
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements); const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
if (!options?.skipValidation) { validateIndicesThrottled(_nextElements);
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
-23
View File
@@ -999,29 +999,6 @@ export const bindPointToSnapToElementOutline = (
intersector, intersector,
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? center[0] : snapPoint[0],
isHorizontal ? center[1] : snapPoint[1],
);
const anotherIntersector = lineSegment(
anotherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
anotherPoint,
),
);
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
anotherIntersector,
FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0];
}
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
+18 -51
View File
@@ -5,7 +5,6 @@ import {
invariant, invariant,
rescalePoints, rescalePoints,
sizeOf, sizeOf,
STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -43,7 +42,6 @@ import {
isBoundToContainer, isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@@ -323,42 +321,19 @@ export const getElementLineSegments = (
if (shape.type === "polycurve") { if (shape.type === "polycurve") {
const curves = shape.data; const curves = shape.data;
const pointsOnCurves = curves.map((curve) => const points = curves
pointsOnBezierCurves(curve, 10), .map((curve) => pointsOnBezierCurves(curve, 10))
); .flat();
let i = 0;
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
if ( segments.push(
(isLineElement(element) && !element.polygon) || lineSegment(
isArrowElement(element) pointFrom(points[i][0], points[i][1]),
) { pointFrom(points[i + 1][0], points[i + 1][1]),
for (const points of pointsOnCurves) { ),
let i = 0; );
i++;
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
}
} else {
const points = pointsOnCurves.flat();
let i = 0;
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
} }
return segments; return segments;
@@ -833,15 +808,9 @@ export const getArrowheadPoints = (
// This value is selected by minimizing a minimum size with the last segment of the arrowhead // This value is selected by minimizing a minimum size with the last segment of the arrowhead
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
// make arrowheads bigger for thick strokes const minSize = Math.min(size, length * lengthMultiplier);
const strokeWidthMultiplier = const xs = x2 - nx * minSize;
element.strokeWidth >= STROKE_WIDTH.extraBold ? 1.5 : 1; const ys = y2 - ny * minSize;
const adjustedSize =
Math.min(size, length * lengthMultiplier) * strokeWidthMultiplier;
const xs = x2 - nx * adjustedSize;
const ys = y2 - ny * adjustedSize;
if ( if (
arrowhead === "dot" || arrowhead === "dot" ||
@@ -890,7 +859,7 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 + adjustedSize * 2, y2), pointFrom(x2 + minSize * 2, y2),
pointFrom(x2, y2), pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
@@ -901,7 +870,7 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - adjustedSize * 2, y2), pointFrom(x2 - minSize * 2, y2),
pointFrom(x2, y2), pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
@@ -1157,9 +1126,7 @@ export interface BoundingBox {
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
+1 -7
View File
@@ -10,13 +10,7 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" || type !== "image" && type !== "frame" && type !== "magicframe";
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
+157 -367
View File
@@ -2,6 +2,7 @@ import {
arrayToMap, arrayToMap,
arrayToObject, arrayToObject,
assertNever, assertNever,
invariant,
isDevEnv, isDevEnv,
isShallowEqual, isShallowEqual,
isTestEnv, isTestEnv,
@@ -55,10 +56,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { StoreSnapshot } from "./store";
import { Scene } from "./Scene"; import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -150,27 +151,13 @@ export class Delta<T> {
); );
} }
/**
* Merges two deltas into a new one.
*/
public static merge<T>(
delta1: Delta<T>,
delta2: Delta<T>,
delta3: Delta<T> = Delta.empty(),
) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
);
}
/** /**
* Merges deleted and inserted object partials. * Merges deleted and inserted object partials.
*/ */
public static mergeObjects<T extends { [key: string]: unknown }>( public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T, prev: T,
added: T, added: T,
removed: T = {} as T, removed: T,
) { ) {
const cloned = { ...prev }; const cloned = { ...prev };
@@ -510,11 +497,6 @@ export interface DeltaContainer<T> {
*/ */
applyTo(previous: T, ...options: unknown[]): [T, boolean]; applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Squashes the current delta with the given one.
*/
squash(delta: DeltaContainer<T>): this;
/** /**
* Checks whether all `Delta`s are empty. * Checks whether all `Delta`s are empty.
*/ */
@@ -522,11 +504,7 @@ export interface DeltaContainer<T> {
} }
export class AppStateDelta implements DeltaContainer<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public delta: Delta<ObservedAppState>) {} private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
return new AppStateDelta(delta);
}
public static calculate<T extends ObservedAppState>( public static calculate<T extends ObservedAppState>(
prevAppState: T, prevAppState: T,
@@ -557,137 +535,76 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return new AppStateDelta(inversedDelta); return new AppStateDelta(inversedDelta);
} }
public squash(delta: AppStateDelta): this {
if (delta.isEmpty()) {
return this;
}
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
this.delta.deleted.selectedElementIds ?? {},
delta.delta.deleted.selectedElementIds ?? {},
);
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
this.delta.inserted.selectedElementIds ?? {},
delta.delta.inserted.selectedElementIds ?? {},
);
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
this.delta.deleted.selectedGroupIds ?? {},
delta.delta.deleted.selectedGroupIds ?? {},
);
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
this.delta.inserted.selectedGroupIds ?? {},
delta.delta.inserted.selectedGroupIds ?? {},
);
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
this.delta.deleted.lockedMultiSelections ?? {},
delta.delta.deleted.lockedMultiSelections ?? {},
);
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
this.delta.inserted.lockedMultiSelections ?? {},
delta.delta.inserted.lockedMultiSelections ?? {},
);
const mergedInserted: Partial<ObservedAppState> = {};
const mergedDeleted: Partial<ObservedAppState> = {};
if (
Object.keys(mergedDeletedSelectedElementIds).length ||
Object.keys(mergedInsertedSelectedElementIds).length
) {
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
}
if (
Object.keys(mergedDeletedSelectedGroupIds).length ||
Object.keys(mergedInsertedSelectedGroupIds).length
) {
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
}
if (
Object.keys(mergedDeletedLockedMultiSelections).length ||
Object.keys(mergedInsertedLockedMultiSelections).length
) {
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
mergedInserted.lockedMultiSelections =
mergedInsertedLockedMultiSelections;
}
this.delta = Delta.merge(
this.delta,
delta.delta,
Delta.create(mergedDeleted, mergedInserted),
);
return this;
}
public applyTo( public applyTo(
appState: AppState, appState: AppState,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
): [AppState, boolean] { ): [AppState, boolean] {
try { try {
const { const {
selectedElementIds: deletedSelectedElementIds = {}, selectedElementIds: removedSelectedElementIds = {},
selectedGroupIds: deletedSelectedGroupIds = {}, selectedGroupIds: removedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
} = this.delta.deleted; } = this.delta.deleted;
const { const {
selectedElementIds: insertedSelectedElementIds = {}, selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: insertedSelectedGroupIds = {}, selectedGroupIds: addedSelectedGroupIds = {},
lockedMultiSelections: insertedLockedMultiSelections = {}, selectedLinearElementId,
selectedLinearElement: insertedSelectedLinearElement, selectedLinearElementIsEditing,
...directlyApplicablePartial ...directlyApplicablePartial
} = this.delta.inserted; } = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects( const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds, appState.selectedElementIds,
insertedSelectedElementIds, addedSelectedElementIds,
deletedSelectedElementIds, removedSelectedElementIds,
); );
const mergedSelectedGroupIds = Delta.mergeObjects( const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds, appState.selectedGroupIds,
insertedSelectedGroupIds, addedSelectedGroupIds,
deletedSelectedGroupIds, removedSelectedGroupIds,
); );
const mergedLockedMultiSelections = Delta.mergeObjects( let selectedLinearElement = appState.selectedLinearElement;
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
);
const selectedLinearElement = if (selectedLinearElementId === null) {
insertedSelectedLinearElement && // Unselect linear element (visible change)
nextElements.has(insertedSelectedLinearElement.elementId) selectedLinearElement = null;
? new LinearElementEditor( } else if (
nextElements.get( selectedLinearElementId &&
insertedSelectedLinearElement.elementId, nextElements.has(selectedLinearElementId)
) as NonDeleted<ExcalidrawLinearElement>, ) {
nextElements, selectedLinearElement = new LinearElementEditor(
insertedSelectedLinearElement.isEditing, nextElements.get(
) selectedLinearElementId,
: null; ) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
);
}
if (
// Value being 'null' is equivaluent to unknown in this case because it only gets set
// to null when 'selectedLinearElementId' is set to null
selectedLinearElementIsEditing != null
) {
invariant(
selectedLinearElement,
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
);
selectedLinearElement = {
...selectedLinearElement,
isEditing: selectedLinearElementIsEditing,
};
}
const nextAppState = { const nextAppState = {
...appState, ...appState,
...directlyApplicablePartial, ...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds, selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds, selectedGroupIds: mergedSelectedGroupIds,
lockedMultiSelections: mergedLockedMultiSelections, selectedLinearElement,
selectedLinearElement:
typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement
: appState.selectedLinearElement,
}; };
const constainsVisibleChanges = this.filterInvisibleChanges( const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -816,53 +733,64 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElement": case "selectedLinearElementId": {
const nextLinearElement = nextAppState[key]; const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!nextLinearElement) { if (!linearElement) {
// previously there was a linear element (assuming visible), now there is none // previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
const element = nextElements.get(nextLinearElement.elementId); const element = nextElements.get(linearElement.elementId);
if (element && !element.isDeleted) { if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible // previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
// there was assigned a linear element now, but it's deleted // there was assigned a linear element now, but it's deleted
nextAppState[key] = null; nextAppState[appStateKey] = null;
} }
} }
break; break;
case "lockedMultiSelections": }
case "selectedLinearElementIsEditing": {
// Changes in editing state are always visible
const prevIsEditing =
prevAppState.selectedLinearElement?.isEditing ?? false;
const nextIsEditing =
nextAppState.selectedLinearElement?.isEditing ?? false;
if (prevIsEditing !== nextIsEditing) {
visibleDifferenceFlag.value = true;
}
break;
}
case "lockedMultiSelections": {
const prevLockedUnits = prevAppState[key] || {}; const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {};
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
case "activeLockedId": }
case "activeLockedId": {
const prevHitLockedId = prevAppState[key] || null; const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null; const nextHitLockedId = nextAppState[key] || null;
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (prevHitLockedId !== nextHitLockedId) { if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
default: }
default: {
assertNever( assertNever(
key, key,
`Unknown ObservedElementsAppState's key "${key}"`, `Unknown ObservedElementsAppState's key "${key}"`,
true, true,
); );
}
} }
} }
} }
@@ -870,6 +798,15 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
): keyof Pick<AppState, "selectedLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -934,7 +871,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
selectedLinearElement, selectedLinearElementId,
selectedLinearElementIsEditing,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -988,6 +926,12 @@ export class AppStateDelta implements DeltaContainer<AppState> {
"lockedMultiSelections", "lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>, (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
); );
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) { } catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`); console.error(`Couldn't postprocess appstate change deltas.`);
@@ -1016,13 +960,12 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">; Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = { export type ApplyToOptions = {
excludedProperties?: Set<keyof ElementPartial>; excludedProperties: Set<keyof ElementPartial>;
}; };
type ApplyToFlags = { type ApplyToFlags = {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
applyDirection: "forward" | "backward" | undefined;
}; };
/** /**
@@ -1111,27 +1054,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => }: Delta<ElementPartial>) =>
!!( !!(
deleted.version &&
inserted.version &&
// versions are required integers // versions are required integers
( Number.isInteger(deleted.version) &&
Number.isInteger(deleted.version) && Number.isInteger(inserted.version) &&
Number.isInteger(inserted.version) && // versions should be positive, zero included
// versions should be positive, zero included deleted.version >= 0 &&
deleted.version! >= 0 && inserted.version >= 0 &&
inserted.version! >= 0 && // versions should never be the same
// versions should never be the same deleted.version !== inserted.version
deleted.version !== inserted.version
)
); );
private static satisfiesUniqueInvariants = (
elementsDelta: ElementsDelta,
id: string,
) => {
const { added, removed, updated } = elementsDelta;
// it's required that there is only one unique delta type per element
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
};
private static validate( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
@@ -1140,7 +1074,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if ( if (
!this.satisfiesCommmonInvariants(delta) || !this.satisfiesCommmonInvariants(delta) ||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
!satifiesSpecialInvariants(delta) !satifiesSpecialInvariants(delta)
) { ) {
console.error( console.error(
@@ -1177,7 +1110,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement } as ElementPartial; const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { const inserted = {
isDeleted: true, isDeleted: true,
@@ -1191,11 +1124,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
if (!prevElement.isDeleted) { removed[prevElement.id] = delta;
removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
}
} }
} }
@@ -1211,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false,
} as ElementPartial; } as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
@@ -1219,12 +1149,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element added[nextElement.id] = delta;
if (!nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
}
continue; continue;
} }
@@ -1253,7 +1178,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
updated[nextElement.id] = delta; // making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
} }
} }
@@ -1268,8 +1196,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, { inserted, deleted }] of Object.entries(deltas)) { for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted }); inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
} }
return inversedDeltas; return inversedDeltas;
@@ -1388,7 +1316,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options?: ApplyToOptions, options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1396,28 +1326,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const flags: ApplyToFlags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
applyDirection: undefined,
}; };
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
elements,
nextElements, nextElements,
snapshot, snapshot,
flags,
options, options,
flags,
); );
const addedElements = applyDeltas(this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts( const affectedElements = this.resolveConflicts(elements, nextElements);
elements,
nextElements,
flags.applyDirection,
);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([ changedElements = new Map([
@@ -1441,15 +1365,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
try { try {
// the following reorder performs mutations, but only on new instances of changed elements, // the following reorder performs also mutations, but only on new instances of changed elements
// unless something goes really bad and it fallbacks to fixing all invalid indices // (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsDelta.reorderElements( nextElements = ElementsDelta.reorderElements(
nextElements, nextElements,
changedElements, changedElements,
flags, flags,
); );
ElementsDelta.redrawElements(nextElements, changedElements); // we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1464,113 +1395,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
} }
public squash(delta: ElementsDelta): this {
if (delta.isEmpty()) {
return this;
}
const { added, removed, updated } = delta;
const mergeBoundElements = (
prevDelta: Delta<ElementPartial>,
nextDelta: Delta<ElementPartial>,
) => {
const mergedDeletedBoundElements =
Delta.mergeArrays(
prevDelta.deleted.boundElements ?? [],
nextDelta.deleted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
const mergedInsertedBoundElements =
Delta.mergeArrays(
prevDelta.inserted.boundElements ?? [],
nextDelta.inserted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
if (
!mergedDeletedBoundElements.length &&
!mergedInsertedBoundElements.length
) {
return;
}
return Delta.create(
{
boundElements: mergedDeletedBoundElements,
},
{
boundElements: mergedInsertedBoundElements,
},
);
};
for (const [id, nextDelta] of Object.entries(added)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.added[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.removed[id];
delete this.updated[id];
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(removed)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.removed[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.added[id];
delete this.updated[id];
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(updated)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.updated[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
if (prevDelta === this.added[id]) {
this.added[id] = updatedDelta;
} else if (prevDelta === this.removed[id]) {
this.removed[id] = updatedDelta;
} else {
this.updated[id] = updatedDelta;
}
}
}
if (isTestEnv() || isDevEnv()) {
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
}
return this;
}
private static createApplier = private static createApplier =
( (
prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"], snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) => ) =>
(deltas: Record<string, Delta<ElementPartial>>) => { (deltas: Record<string, Delta<ElementPartial>>) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
@@ -1583,26 +1413,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const nextElement = ElementsDelta.applyDelta( const newElement = ElementsDelta.applyDelta(
element, element,
delta, delta,
flags,
options, options,
flags,
); );
nextElements.set(nextElement.id, nextElement); nextElements.set(newElement.id, newElement);
acc.set(nextElement.id, nextElement); acc.set(newElement.id, newElement);
if (!flags.applyDirection) {
const prevElement = prevElements.get(id);
if (prevElement) {
flags.applyDirection =
prevElement.version > nextElement.version
? "backward"
: "forward";
}
}
} }
return acc; return acc;
@@ -1647,8 +1466,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) { ) {
const directlyApplicablePartial: Mutable<ElementPartial> = {}; const directlyApplicablePartial: Mutable<ElementPartial> = {};
@@ -1662,7 +1481,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
if (options?.excludedProperties?.has(key)) { if (options.excludedProperties.has(key)) {
continue; continue;
} }
@@ -1702,7 +1521,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index; delta.deleted.index !== delta.inserted.index;
} }
return newElementWith(element, directlyApplicablePartial, true); return newElementWith(element, directlyApplicablePartial);
} }
/** /**
@@ -1742,7 +1561,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private resolveConflicts( private resolveConflicts(
prevElements: SceneElementsMap, prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
applyDirection: "forward" | "backward" = "forward",
) { ) {
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>(); const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
const updater = ( const updater = (
@@ -1754,36 +1572,21 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return; return;
} }
const prevElement = prevElements.get(element.id);
const nextVersion =
applyDirection === "forward"
? nextElement.version + 1
: nextElement.version - 1;
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
let affectedElement: OrderedExcalidrawElement; let affectedElement: OrderedExcalidrawElement;
if (prevElement === nextElement) { if (prevElements.get(element.id) === nextElement) {
// create the new element instance in case we didn't modify the element yet // create the new element instance in case we didn't modify the element yet
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
affectedElement = newElementWith( affectedElement = newElementWith(
nextElement, nextElement,
{ updates as ElementUpdate<OrderedExcalidrawElement>,
...elementUpdates,
version: nextVersion,
},
true,
); );
} else { } else {
affectedElement = mutateElement(nextElement, nextElements, { affectedElement = mutateElement(
...elementUpdates, nextElement,
// don't modify the version further, if it's already different nextElements,
version: updates as ElementUpdate<OrderedExcalidrawElement>,
prevElement?.version !== nextElement.version );
? nextElement.version
: nextVersion,
});
} }
nextAffectedElements.set(affectedElement.id, affectedElement); nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1821,12 +1624,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
); );
// calculate complete deltas for affected elements, and squash them back to the current deltas // calculate complete deltas for affected elements, and assign them back to all the deltas
this.squash( // technically we could do better here if perf. would become an issue
// technically we could do better here if perf. would become an issue const { added, removed, updated } = ElementsDelta.calculate(
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements), prevAffectedElements,
nextAffectedElements,
); );
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements; return nextAffectedElements;
} }
@@ -1888,31 +1704,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
BindableElement.rebindAffected(nextElements, nextElement(), updater); BindableElement.rebindAffected(nextElements, nextElement(), updater);
} }
public static redrawElements(
nextElements: SceneElementsMap,
changedElements: Map<string, OrderedExcalidrawElement>,
) {
try {
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements, { skipValidation: true });
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// needs ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(`Couldn't redraw elements`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return nextElements;
}
}
private static redrawTextBoundingBoxes( private static redrawTextBoundingBoxes(
scene: Scene, scene: Scene,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
@@ -1967,7 +1758,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
changedElements: changed, changedElements: changed,
}); });
+17 -30
View File
@@ -359,12 +359,6 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@@ -712,7 +706,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ) => {
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -747,15 +741,8 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2); const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal( const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@@ -814,19 +801,10 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = globalUpdatedPoints.at( const secondToLastPoint =
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
); const thirdToLastPoint =
const thirdToLastPoint = globalUpdatedPoints.at( globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading); const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal( const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint, thirdToLastPoint,
@@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>
-373
View File
@@ -1,373 +0,0 @@
import { LaserPointer, type Point } from "@excalidraw/laser-pointer";
import {
clamp,
lineSegment,
pointFrom,
pointRotateRads,
round,
type LocalPoint,
} from "@excalidraw/math";
import getStroke from "perfect-freehand";
import { invariant } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import { getElementBounds } from "./bounds";
import type { StrokeOptions } from "perfect-freehand";
import type {
ElementsMap,
ExcalidrawFreeDrawElement,
PointerType,
} from "./types";
export const STROKE_OPTIONS: Record<
PointerType | "default",
{ streamline: number; simplify: number }
> = {
default: {
streamline: 0.35,
simplify: 0.1,
},
mouse: {
streamline: 0.6,
simplify: 0.1,
},
pen: {
// for optimal performance, we use a lower streamline and simplify
streamline: 0.2,
simplify: 0.1,
},
touch: {
streamline: 0.65,
simplify: 0.1,
},
} as const;
export const getFreedrawConfig = (eventType: string | null | undefined) => {
return (
STROKE_OPTIONS[(eventType as PointerType | null) || "default"] ||
STROKE_OPTIONS.default
);
};
/**
* Calculates simulated pressure based on velocity between consecutive points.
* Fast movement (large distances) -> lower pressure
* Slow movement (small distances) -> higher pressure
*/
const calculateVelocityBasedPressure = (
points: readonly LocalPoint[],
index: number,
fixedStrokeWidth: boolean | undefined,
maxDistance = 8, // Maximum expected distance for normalization
): number => {
if (fixedStrokeWidth) {
return 1;
}
// First point gets highest pressure
// This avoid "a dot followed by a line" effect, •== when first stroke is "slow"
if (index === 0) {
return 1;
}
const [x1, y1] = points[index - 1];
const [x2, y2] = points[index];
// Calculate distance between consecutive points
const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
// Normalize distance and invert for pressure (0 = fast/low pressure, 1 = slow/high pressure)
const normalizedDistance = Math.min(distance / maxDistance, 1);
const basePressure = Math.max(0.1, 1 - normalizedDistance * 0.7); // Range: 0.1 to 1.0
const constantPressure = 0.5;
const pressure = constantPressure + (basePressure - constantPressure);
return Math.max(0.1, Math.min(1.0, pressure));
};
export const getFreedrawStroke = (element: ExcalidrawFreeDrawElement) => {
// Compose points as [x, y, pressure]
let points: [number, number, number][];
if (element.freedrawOptions?.fixedStrokeWidth) {
points = element.points.map(
([x, y]: LocalPoint): [number, number, number] => [x, y, 1],
);
} else if (element.simulatePressure) {
// Simulate pressure based on velocity between consecutive points
points = element.points.map(([x, y]: LocalPoint, i) => [
x,
y,
calculateVelocityBasedPressure(
element.points,
i,
element.freedrawOptions?.fixedStrokeWidth,
),
]);
} else {
points = element.points.map(([x, y]: LocalPoint, i) => {
const rawPressure = element.pressures?.[i] ?? 0.5;
const amplifiedPressure = Math.pow(rawPressure, 0.6);
const adjustedPressure = amplifiedPressure;
return [x, y, clamp(adjustedPressure, 0.1, 1.0)];
});
}
const streamline =
element.freedrawOptions?.streamline ?? STROKE_OPTIONS.default.streamline;
const simplify =
element.freedrawOptions?.simplify ?? STROKE_OPTIONS.default.simplify;
const laser = new LaserPointer({
size: element.strokeWidth,
streamline,
simplify,
sizeMapping: ({ pressure: t }) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.6;
}
if (element.simulatePressure) {
return 0.2 + t * 0.6;
}
return 0.2 + t * 0.8;
},
});
for (const pt of points) {
laser.addPoint(pt);
}
laser.close();
return laser.getStrokeOutline();
};
/**
* Generates an SVG path for a freedraw element using LaserPointer logic.
* Uses actual pressure data if available, otherwise simulates pressure based on velocity.
* No streamline, smoothing, or simulation is performed.
*/
export const getFreeDrawSvgPath = (
element: ExcalidrawFreeDrawElement,
): string => {
// legacy, for backwards compatibility
if (element.freedrawOptions === null) {
return _legacy_getFreeDrawSvgPath(element);
}
return _transition_getFreeDrawSvgPath(element);
// return getSvgPathFromStroke(getFreedrawStroke(element));
};
const roundPoint = (A: Point): string => {
return `${round(A[0], 4, "round")},${round(A[1], 4, "round")} `;
};
const average = (A: Point, B: Point): string => {
return `${round((A[0] + B[0]) / 2, 4, "round")},${round(
(A[1] + B[1]) / 2,
4,
"round",
)} `;
};
export const getSvgPathFromStroke = (points: Point[]): string => {
const len = points.length;
if (len < 2) {
return "";
}
let a = points[0];
let b = points[1];
if (len === 2) {
return `M${roundPoint(a)}L${roundPoint(b)}`;
}
let result = "";
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += average(a, b);
}
return `M${roundPoint(points[0])}Q${roundPoint(points[1])}${average(
points[1],
points[2],
)}${points.length > 3 ? "T" : ""}${result}L${roundPoint(points[len - 1])}`;
};
function _transition_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
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,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.5;
}
return Math.sin((t * Math.PI) / 2) * 0.65;
}, // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
function _legacy_getFreeDrawSvgPath(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: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
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 _legacy_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");
};
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
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: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}
-5
View File
@@ -29,9 +29,6 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
// string hash function (using djb2). Not cryptographically secure, use only // string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such. // for versioning and such.
// note: hashes individual code units (not code points),
// but for hashing purposes this is fine as it iterates through every code unit
// (as such, no need to encode to byte string first)
export const hashString = (s: string): number => { export const hashString = (s: string): number => {
let hash: number = 5381; let hash: number = 5381;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
@@ -94,14 +91,12 @@ export * from "./embeddable";
export * from "./flowchart"; export * from "./flowchart";
export * from "./fractionalIndex"; export * from "./fractionalIndex";
export * from "./frame"; export * from "./frame";
export * from "./freedraw";
export * from "./groups"; export * from "./groups";
export * from "./heading"; export * from "./heading";
export * from "./image"; export * from "./image";
export * from "./linearElementEditor"; export * from "./linearElementEditor";
export * from "./mutateElement"; export * from "./mutateElement";
export * from "./newElement"; export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
-6
View File
@@ -445,7 +445,6 @@ export const newFreeDrawElement = (
points?: ExcalidrawFreeDrawElement["points"]; points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean; simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
strokeOptions?: ExcalidrawFreeDrawElement["freedrawOptions"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
return { return {
@@ -454,11 +453,6 @@ export const newFreeDrawElement = (
pressures: opts.pressures || [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null, lastCommittedPoint: null,
freedrawOptions: opts.strokeOptions || {
fixedStrokeWidth: true,
streamline: 0.25,
simplify: 0.1,
},
}; };
}; };
@@ -1,112 +0,0 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};
+56 -2
View File
@@ -1,4 +1,5 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { isRightAngleRads } from "@excalidraw/math"; import { isRightAngleRads } from "@excalidraw/math";
@@ -57,8 +58,6 @@ import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape"; import { ShapeCache } from "./shape";
import { getFreeDrawSvgPath } from "./freedraw";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -71,6 +70,7 @@ import type {
ElementsMap, ElementsMap,
} 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 // using a stronger invert (100% vs our regular 93%) and saturate
@@ -1037,3 +1037,57 @@ export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element); return pathsCache.get(element);
} }
export function getFreeDrawSvgPath(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: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
}
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");
}
+3 -19
View File
@@ -35,7 +35,6 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@@ -226,16 +225,7 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( scene.mutateElement(textElement, { angle });
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@@ -426,15 +416,9 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, { scene.mutateElement(boundText, {
x, x: boundText.x + (rotatedCX - cx),
y, y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }
+10 -17
View File
@@ -21,7 +21,6 @@ import {
assertNever, assertNever,
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -203,7 +202,7 @@ export const generateRoughOptions = (
// hachureGap because if not specified, roughjs uses strokeWidth to // hachureGap because if not specified, roughjs uses strokeWidth to
// calculate them (and we don't want the fills to be modified) // calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: Math.min(element.strokeWidth, STROKE_WIDTH.bold) * 4, hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: element.strokeColor, stroke: element.strokeColor,
preserveVertices: preserveVertices:
@@ -807,21 +806,15 @@ const generateElementShape = (
generateFreeDrawShape(element); generateFreeDrawShape(element);
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
const points = // generate rough polygon to fill freedraw shape
element.freedrawOptions === null const simplifiedPoints = simplify(
? simplify(element.points as LocalPoint[], 0.75) element.points as Mutable<LocalPoint[]>,
: simplify(element.points as LocalPoint[], 1.5); 0.75,
);
shape = shape = generator.curve(simplifiedPoints as [number, number][], {
element.freedrawOptions === null ...generateRoughOptions(element),
? generator.curve(points, { stroke: "none",
...generateRoughOptions(element), });
stroke: "none",
})
: generator.polygon(points, {
...generateRoughOptions(element),
stroke: "none",
});
} else { } else {
shape = null; shape = null;
} }
+16 -31
View File
@@ -76,9 +76,8 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events. * Store which captures the observed changes and emits them as `StoreIncrement` events.
*/ */
export class Store { export class Store {
// for internal use by history // internally used by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter< public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement] [DurableIncrement | EphemeralIncrement]
>(); >();
@@ -240,6 +239,7 @@ export class Store {
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta); const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment); this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
} }
@@ -552,26 +552,10 @@ export class StoreDelta {
public static load({ public static load({
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, appState); return new this(id, elements, AppStateDelta.empty());
}
/**
* Squash the passed deltas into the aggregated delta instance.
*/
public static squash(...deltas: StoreDelta[]) {
const aggregatedDelta = StoreDelta.empty();
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
}
return aggregatedDelta;
} }
/** /**
@@ -588,7 +572,9 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options?: ApplyToOptions, options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements, elements,
@@ -627,10 +613,6 @@ export class StoreDelta {
); );
} }
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@@ -996,7 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedLinearElement: null, selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
croppingElementId: null, croppingElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
@@ -1015,12 +998,14 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElement: appState.selectedLinearElement selectedLinearElementId:
? { (appState as AppState).selectedLinearElement?.elementId ??
elementId: appState.selectedLinearElement.elementId, (appState as ObservedAppState).selectedLinearElementId ??
isEditing: !!appState.selectedLinearElement.isEditing, null,
} selectedLinearElementIsEditing:
: null, (appState as AppState).selectedLinearElement?.isEditing ??
(appState as ObservedAppState).selectedLinearElementIsEditing ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+2 -22
View File
@@ -10,12 +10,12 @@ import {
invariant, invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
x = x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
} }
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y }; return { x, y };
}; };
+2 -5
View File
@@ -2,7 +2,6 @@ import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid, isAndroid,
isIOS, isIOS,
isMobileOrTablet,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
@@ -327,7 +326,7 @@ export const getTransformHandles = (
); );
}; };
export const hasBoundingBox = ( export const shouldShowBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
) => { ) => {
@@ -346,7 +345,5 @@ export const hasBoundingBox = (
return true; return true;
} }
// on mobile/tablet we currently don't show bbox because of resize issues return element.points.length > 2;
// (also prob best for simplicity's sake)
return element.points.length > 2 && !isMobileOrTablet();
}; };
-5
View File
@@ -380,11 +380,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
pressures: readonly number[]; pressures: readonly number[];
simulatePressure: boolean; simulatePressure: boolean;
lastCommittedPoint: LocalPoint | null; lastCommittedPoint: LocalPoint | null;
freedrawOptions: {
streamline?: number;
simplify?: number;
fixedStrokeWidth?: boolean;
} | null;
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
+8 -6
View File
@@ -10,8 +10,6 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { LinearElementEditor } from "@excalidraw/element";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import { import {
getTextEditor, getTextEditor,
@@ -415,12 +413,16 @@ describe("element binding", () => {
expect(arrow.endBinding?.elementId).toBe(rectRight.id); expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// Drag arrow off of bound rectangle range // Drag arrow off of bound rectangle range
const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates( const handles = getTransformHandles(
arrow, arrow,
-1, h.state.zoom,
h.scene.getNonDeletedElementsMap(), arrayToMap(h.elements),
); "mouse",
).se!;
Keyboard.keyDown(KEYS.CTRL_OR_CMD); Keyboard.keyDown(KEYS.CTRL_OR_CMD);
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
mouse.downAt(elX, elY); mouse.downAt(elX, elY);
mouse.moveTo(300, 400); mouse.moveTo(300, 400);
mouse.up(); mouse.up();
+12 -433
View File
@@ -1,345 +1,13 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types"; import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta"; import { AppStateDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => { describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => { describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => { it("should maintain stable order for root properties", () => {
const name = "untitled scene"; const name = "untitled scene";
const selectedLinearElement = { const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = { const commonAppState = {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
@@ -356,23 +24,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = { const prevAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name: "", name: "",
selectedLinearElement: null, selectedLinearElementId: null,
}; };
const nextAppState1: ObservedAppState = { const nextAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name, name,
selectedLinearElement, selectedLinearElementId,
}; };
const prevAppState2: ObservedAppState = { const prevAppState2: ObservedAppState = {
selectedLinearElement: null, selectedLinearElementId: null,
name: "", name: "",
...commonAppState, ...commonAppState,
}; };
const nextAppState2: ObservedAppState = { const nextAppState2: ObservedAppState = {
selectedLinearElement, selectedLinearElementId,
name, name,
...commonAppState, ...commonAppState,
}; };
@@ -390,7 +58,9 @@ describe("AppStateDelta", () => {
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElement: null, selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -436,7 +106,9 @@ describe("AppStateDelta", () => {
selectedElementIds: {}, selectedElementIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElement: null, selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -477,97 +149,4 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
}); });
}); });
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
}); });
+1 -171
View File
@@ -1,14 +1,13 @@
import { getLineHeight } from "@excalidraw/common"; import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement"; } from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});
+1 -3
View File
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element";
@@ -30,8 +30,6 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -8,7 +8,6 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
isBoundToContainer,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -226,9 +225,7 @@ export const actionWrapTextInContainer = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const someTextElements = selectedElements.some( const someTextElements = selectedElements.some((el) => isTextElement(el));
(el) => isTextElement(el) && !isBoundToContainer(el),
);
return selectedElements.length > 0 && someTextElements; return selectedElements.length > 0 && someTextElements;
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@@ -237,7 +234,7 @@ export const actionWrapTextInContainer = register({
const containerIds: Mutable<AppState["selectedElementIds"]> = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement) && !isBoundToContainer(textElement)) { if (isTextElement(textElement)) {
const container = newElement({ const container = newElement({
type: "rectangle", type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor, backgroundColor: appState.currentItemBackgroundColor,
+5 -12
View File
@@ -7,6 +7,7 @@ import {
MIN_ZOOM, MIN_ZOOM,
THEME, THEME,
ZOOM_STEP, ZOOM_STEP,
getShortcutKey,
updateActiveTool, updateActiveTool,
CODES, CODES,
KEYS, KEYS,
@@ -45,7 +46,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
@@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps, data }) => { PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx // FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<ColorPicker <ColorPicker
@@ -83,7 +83,6 @@ export const actionChangeViewBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
); );
}, },
@@ -122,10 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ? { ...appState.activeTool, type: "selection" }
...appState.activeTool,
type: app.state.preferredSelectionTool.type,
}
: appState.activeTool, : appState.activeTool,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -498,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool", name: "toggleEraserTool",
label: "toolBar.eraser", label: "toolBar.eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => { perform: (elements, appState) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type, type: "selection",
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -534,9 +530,6 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso", label: "toolBar.lasso",
icon: LassoIcon, icon: LassoIcon,
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.state.preferredSelectionTool.type !== "lasso";
},
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
@@ -1,8 +1,4 @@
import { import { KEYS, updateActiveTool } from "@excalidraw/common";
KEYS,
MOBILE_ACTION_BUTTON_BG,
updateActiveTool,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element"; import { fixBindingsAfterDeletion } from "@excalidraw/element";
@@ -302,9 +298,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { activeTool: updateActiveTool(appState, { type: "selection" }),
type: app.state.preferredSelectionTool.type,
}),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
@@ -327,15 +321,7 @@ export const actionDeleteSelected = register({
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
disabled={ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(appState.stylesPanelMode === "mobile" &&
appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
), ),
}); });
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
@@ -26,8 +26,6 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@@ -1,8 +1,8 @@
import { import {
DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE,
KEYS, KEYS,
MOBILE_ACTION_BUTTON_BG,
arrayToMap, arrayToMap,
getShortcutKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@@ -25,7 +25,6 @@ import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
@@ -116,15 +115,7 @@ export const actionDuplicateSelection = register({
)}`} )}`}
aria-label={t("labels.duplicateSelection")} aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
disabled={ visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(appState.stylesPanelMode === "mobile" &&
appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
), ),
}); });
+6 -27
View File
@@ -5,11 +5,7 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
@@ -82,14 +78,7 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.map((el) => { newElements = newElements.filter((el) => el.id !== element!.id);
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
} }
return { return {
elements: newElements, elements: newElements,
@@ -128,12 +117,7 @@ export const actionFinalize = register({
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
? elements.map((el) => { ? elements.filter((el) => el.id !== element.id)
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
@@ -188,12 +172,7 @@ export const actionFinalize = register({
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.map((el) => { newElements = newElements.filter((el) => el.id !== element!.id);
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
} }
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
@@ -261,13 +240,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type, type: "selection",
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type, type: "selection",
}); });
} }
+1 -3
View File
@@ -14,7 +14,7 @@ import {
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, randomId, arrayToMap } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@@ -43,8 +43,6 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
+3 -19
View File
@@ -1,10 +1,4 @@
import { import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
isWindows,
KEYS,
matchKey,
arrayToMap,
MOBILE_ACTION_BUTTON_BG,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -73,7 +67,7 @@ export const createUndoAction: ActionCreator = (history) => ({
), ),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -91,11 +85,6 @@ export const createUndoAction: ActionCreator = (history) => ({
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isUndoStackEmpty} disabled={isUndoStackEmpty}
data-testid="button-undo" data-testid="button-undo"
style={{
...(appState.stylesPanelMode === "mobile"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
); );
}, },
@@ -114,7 +103,7 @@ export const createRedoAction: ActionCreator = (history) => ({
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -132,11 +121,6 @@ export const createRedoAction: ActionCreator = (history) => ({
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isRedoStackEmpty} disabled={isRedoStackEmpty}
data-testid="button-redo" data-testid="button-redo"
style={{
...(appState.stylesPanelMode === "mobile"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
); );
}, },
@@ -88,10 +88,6 @@ export const actionToggleLinearEditor = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t( const label = t(
selectedElement.type === "arrow" selectedElement.type === "arrow"
? "labels.lineEditor.editArrow" ? "labels.lineEditor.editArrow"
+2 -2
View File
@@ -1,6 +1,6 @@
import { isEmbeddableElement } from "@excalidraw/element"; import { isEmbeddableElement } from "@excalidraw/element";
import { KEYS } from "@excalidraw/common"; import { KEYS, getShortcutKey } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons"; import { LinkIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
+55 -3
View File
@@ -1,11 +1,65 @@
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { HelpIconThin } from "../components/icons"; import { ToolButton } from "../components/ToolButton";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
label: "buttons.menu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
type="button"
icon={HamburgerMenuIcon}
aria-label={t("buttons.menu")}
onClick={updateData}
selected={appState.openMenu === "canvas"}
/>
),
});
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
label: "buttons.edit",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
visible={showSelectedShapeActions(
appState,
getNonDeletedElements(elements),
)}
type="button"
icon={palette}
aria-label={t("buttons.edit")}
onClick={updateData}
selected={appState.openMenu === "shape"}
/>
),
});
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
label: "welcomeScreen.defaults.helpHint", label: "welcomeScreen.defaults.helpHint",
@@ -25,8 +79,6 @@ export const actionShortcuts = register({
: { : {
name: "help", name: "help",
}, },
openMenu: null,
openPopup: null,
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
@@ -145,27 +145,26 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`), queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-medium`), queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-bold`), queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked(); ).not.toBeChecked();
}); });
it("should show properties of different element types when selected", () => { it("should show properties of different element types when selected", () => {
const rect = API.createElement({ const rect = API.createElement({
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.medium, strokeWidth: STROKE_WIDTH.bold,
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"], fontFamily: FONT_FAMILY["Comic Shanns"],
strokeWidth: undefined,
}); });
API.setElements([rect, text]); API.setElements([rect, text]);
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active", "active",
); );
+55 -206
View File
@@ -17,6 +17,7 @@ import {
randomInteger, randomInteger,
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
@@ -43,7 +44,6 @@ import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
isTextElement, isTextElement,
@@ -126,9 +126,6 @@ import {
ArrowheadCrowfootIcon, ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon, ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCrowfootOneOrManyIcon,
strokeWidthFixedIcon,
strokeWidthVariableIcon,
StrokeWidthMediumIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -140,13 +137,6 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -331,11 +321,9 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
{appState.stylesPanelMode === "full" && ( <h3 aria-hidden="true">{t("labels.stroke")}</h3>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
)}
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS} topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
@@ -353,10 +341,6 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@@ -414,11 +398,9 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
{appState.stylesPanelMode === "full" && ( <h3 aria-hidden="true">{t("labels.background")}</h3>
<h3 aria-hidden="true">{t("labels.background")}</h3>
)}
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
@@ -436,10 +418,6 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@@ -525,33 +503,6 @@ export const actionChangeFillStyle = register({
}, },
}); });
const WIDTHS = [
{
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.medium,
text: t("labels.medium"),
icon: StrokeWidthMediumIcon,
testId: "strokeWidth-medium",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
];
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
@@ -567,13 +518,32 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="stroke-width" group="stroke-width"
options={WIDTHS} options={[
{
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue( value={getFormValue(
elements, elements,
app, app,
@@ -605,7 +575,7 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -658,7 +628,7 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeStyle")}</legend> <legend>{t("labels.strokeStyle")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -696,70 +666,6 @@ export const actionChangeStrokeStyle = register({
), ),
}); });
export const actionChangePressureSensitivity = register({
name: "changeStrokeType",
label: "labels.strokeType",
trackEvent: false,
perform: (elements, appState, value) => {
const updatedElements = changeProperty(elements, appState, (el) => {
if (isFreeDrawElement(el)) {
return newElementWith(el, {
freedrawOptions: {
...el.freedrawOptions,
fixedStrokeWidth: value,
},
});
}
return el;
});
return {
elements: updatedElements,
appState: { ...appState, currentItemFixedStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ app, appState, updateData }) => {
const selectedElements = app.scene.getSelectedElements(app.state);
const freedraws = selectedElements.filter(isFreeDrawElement);
const currentValue =
freedraws.length > 0
? reduceToCommonValue(
freedraws,
(element) => element.freedrawOptions?.fixedStrokeWidth,
) ?? null
: appState.currentItemFixedStrokeWidth;
return (
<fieldset>
<legend>{t("labels.strokeType")}</legend>
<div className="buttonList">
<RadioSelection
group="pressure-sensitivity"
options={[
{
value: true,
text: t("labels.strokeWidthFixed"),
icon: strokeWidthFixedIcon,
testId: "pressure-fixed",
},
{
value: false,
text: t("labels.strokeWidthVariable"),
icon: strokeWidthVariableIcon,
testId: "pressure-variable",
},
]}
value={currentValue}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
export const actionChangeOpacity = register({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
label: "labels.opacity", label: "labels.opacity",
@@ -791,7 +697,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value); return changeFontSize(elements, appState, app, () => value, value);
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -850,15 +756,7 @@ export const actionChangeFontSize = register({
? null ? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE, : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1195,30 +1093,21 @@ export const actionChangeFontFamily = register({
}, []); }, []);
return ( return (
<> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.fontFamily")}</legend>
<legend>{t("labels.fontFamily")}</legend>
)}
<FontPicker <FontPicker
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode !== "full"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( setBatchedData({
() => { openPopup: null,
setBatchedData({ currentHoveredFontFamily: null,
openPopup: null, currentItemFontFamily: fontFamily,
currentHoveredFontFamily: null, });
currentItemFontFamily: fontFamily,
}); // defensive clear so immediate close won't abuse the cached elements
// defensive clear so immediate close won't abuse the cached elements cachedElementsRef.current.clear();
cachedElementsRef.current.clear();
},
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@@ -1275,33 +1164,29 @@ export const actionChangeFontFamily = register({
} }
setBatchedData({ setBatchedData({
...batchedData,
openPopup: "fontFamily", openPopup: "fontFamily",
}); });
} else { } else {
const fontFamilyData = { // close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current), cachedElements: new Map(cachedElementsRef.current),
resetAll: true, resetAll: true,
} as ChangeFontFamilyData; } as ChangeFontFamilyData;
setBatchedData({ if (isUnmounted.current) {
...fontFamilyData, // in case the component was unmounted by the parent, trigger the update directly
}); updateData({ ...batchedData, ...data });
cachedElementsRef.current.clear(); } else {
setBatchedData(data);
// Refocus text editor when font picker closes if we were editing text
if (
(appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile") &&
appState.editingTextElement
) {
restoreCaretPosition(null); // Just refocus without saved position
} }
cachedElementsRef.current.clear();
} }
}} }}
/> />
</> </fieldset>
); );
}, },
}); });
@@ -1340,9 +1225,8 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@@ -1391,15 +1275,7 @@ export const actionChangeTextAlign = register({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1441,7 +1317,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@@ -1491,15 +1367,7 @@ export const actionChangeVerticalAlign = register({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1748,25 +1616,6 @@ export const actionChangeArrowhead = register({
}, },
}); });
export const actionChangeArrowProperties = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowhead")}
{renderAction("changeArrowType")}
</div>
);
},
});
export const actionChangeArrowType = register({ export const actionChangeArrowType = register({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",
+1 -2
View File
@@ -1,4 +1,4 @@
import { KEYS, CODES, isDarwin } from "@excalidraw/common"; import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
import { import {
moveOneLeft, moveOneLeft,
@@ -16,7 +16,6 @@ import {
SendToBackIcon, SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
+5 -3
View File
@@ -13,13 +13,11 @@ export {
actionChangeStrokeWidth, actionChangeStrokeWidth,
actionChangeFillStyle, actionChangeFillStyle,
actionChangeSloppiness, actionChangeSloppiness,
actionChangePressureSensitivity,
actionChangeOpacity, actionChangeOpacity,
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -45,7 +43,11 @@ export {
} from "./actionExport"; } from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { actionShortcuts } from "./actionMenu"; export {
actionToggleCanvasMenu,
actionToggleEditMenu,
actionShortcuts,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";
+1 -2
View File
@@ -1,9 +1,8 @@
import { isDarwin } from "@excalidraw/common"; import { isDarwin, getShortcutKey } from "@excalidraw/common";
import type { SubtypeOf } from "@excalidraw/common/utility-types"; import type { SubtypeOf } from "@excalidraw/common/utility-types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import type { ActionName } from "./types"; import type { ActionName } from "./types";
+2 -2
View File
@@ -69,10 +69,10 @@ export type ActionName =
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeStrokeType"
| "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu"
| "toggleEditMenu"
| "undo" | "undo"
| "redo" | "redo"
| "finalize" | "finalize"
-13
View File
@@ -34,7 +34,6 @@ export const getDefaultAppState = (): Omit<
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity, currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemFixedStrokeWidth: true,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
@@ -56,10 +55,6 @@ export const getDefaultAppState = (): Omit<
fromSelection: false, fromSelection: false,
lastActiveTool: null, lastActiveTool: null,
}, },
preferredSelectionTool: {
type: "selection",
initialized: false,
},
penMode: false, penMode: false,
penDetected: false, penDetected: false,
errorMessage: null, errorMessage: null,
@@ -128,7 +123,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
stylesPanelMode: "full",
}; };
}; };
@@ -168,11 +162,6 @@ const APP_STATE_STORAGE_CONF = (<
server: false, server: false,
}, },
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemFixedStrokeWidth: {
browser: true,
export: false,
server: false,
},
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeColor: { browser: true, export: false, server: false },
@@ -186,7 +175,6 @@ const APP_STATE_STORAGE_CONF = (<
editingTextElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
preferredSelectionTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
@@ -259,7 +247,6 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true }, lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false }, activeLockedId: { browser: false, export: false, server: false },
stylesPanelMode: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+62 -87
View File
@@ -1,7 +1,6 @@
import { import {
createPasteEvent, createPasteEvent,
parseClipboard, parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON, serializeAsClipboardJSON,
} from "./clipboard"; } from "./clipboard";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
@@ -14,9 +13,7 @@ describe("parseClipboard()", () => {
text = "123"; text = "123";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@@ -24,9 +21,7 @@ describe("parseClipboard()", () => {
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@@ -34,9 +29,7 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 }); text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
@@ -46,13 +39,11 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/plain": json,
"text/plain": json, },
}, }),
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
}); });
@@ -65,25 +56,21 @@ describe("parseClipboard()", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": json,
"text/html": json, },
}, }),
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<div> ${json}</div>`,
"text/html": `<div> ${json}</div>`, },
}, }),
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -93,13 +80,11 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<img src="https://example.com/image.png" />`,
"text/html": `<img src="https://example.com/image.png" />`, },
}, }),
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -109,13 +94,11 @@ describe("parseClipboard()", () => {
]); ]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`, },
}, }),
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -131,13 +114,11 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => { it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`, },
}, }),
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -160,16 +141,14 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/plain": `a b
"text/plain": `a b 1 2
1 2 4 5
4 5 7 10`,
7 10`, },
}, }),
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@@ -178,16 +157,14 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `a b
"text/html": `a b 1 2
1 2 4 5
4 5 7 10`,
7 10`, },
}, }),
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@@ -196,21 +173,19 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<html>
"text/html": `<html> <body>
<body> <!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment--> </body>
</body> </html>`,
</html>`, "text/plain": `a b
"text/plain": `a b 1 2
1 2 4 5
4 5 7 10`,
7 10`, },
}, }),
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
+18 -174
View File
@@ -5,7 +5,6 @@ import {
arrayToMap, arrayToMap,
isMemberOf, isMemberOf,
isPromiseLike, isPromiseLike,
EVENT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element";
@@ -17,26 +16,15 @@ import {
import { getContainingFrame } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors"; import { ExcalidrawError } from "./errors";
import { import { createFile, isSupportedImageFileType } from "./data/blob";
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
@@ -104,7 +92,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided"); console.warn("createPasteEvent: no types or files provided");
} }
const event = new ClipboardEvent(EVENT.PASTE, { const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(), clipboardData: new DataTransfer(),
}); });
@@ -113,11 +101,10 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
event.clipboardData?.items.add(value, type); event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`); throw new Error(`Failed to set "${type}" as clipboardData item`);
} }
@@ -242,10 +229,14 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLDataItem = ( const maybeParseHTMLPaste = (
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>, event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = dataItem.value; const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@@ -341,21 +332,18 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEventTextData = async (
dataList: ParsedDataTranferList, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEventTextData> => {
try { try {
const htmlItem = dataList.findByType(MIME_TYPES.html); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) { if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) { if (mixedContent.value.every((item) => item.type === "text")) {
return { return {
type: "text", type: "text",
value: value:
dataList.getData(MIME_TYPES.text) ?? event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@@ -366,156 +354,23 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
return { const text = event.clipboardData?.getData(MIME_TYPES.text);
type: "text",
value: (dataList.getData(MIME_TYPES.text) || "").trim(), return { type: "text", value: (text || "").trim() };
};
} catch { } catch {
return { type: "text", value: "" }; return { type: "text", value: "" };
} }
}; };
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
let file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
file = await normalizeFile(file);
return {
type: file.type,
kind: "file",
file,
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/** /**
* Attempts to parse clipboard event. * Attempts to parse clipboard event.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
dataList: ParsedDataTranferList, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEventTextData(
dataList, event,
isPlainPaste, isPlainPaste,
); );
@@ -664,14 +519,3 @@ const copyTextViaExecCommand = (text: string | null) => {
return success; return success;
}; };
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};
-115
View File
@@ -91,118 +91,3 @@
} }
} }
} }
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
pointer-events: auto;
--default-button-size: 2rem;
.compact-action-button {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
border: none;
border-radius: var(--border-radius-lg);
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
background: var(--mobile-action-button-bg);
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&.active {
background: var(
--color-surface-primary-container,
var(--mobile-action-button-bg)
);
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
.ToolIcon {
.ToolIcon__icon {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
background: var(--mobile-action-button-bg);
&:hover {
background-color: transparent;
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.mobile-shape-actions {
z-index: 999;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
background: transparent;
border-radius: var(--border-radius-lg);
box-shadow: none;
overflow: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,8 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS } from "@excalidraw/common"; import { KEYS, getShortcutKey } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useDevice } from "../App"; import { useDevice } from "../App";
@@ -7,12 +7,6 @@
} }
} }
.color-picker__title {
padding: 0 0.5rem;
font-size: 0.875rem;
text-align: left;
}
.color-picker__heading { .color-picker__heading {
padding: 0 0.5rem; padding: 0 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -28,12 +22,6 @@
@include isMobile { @include isMobile {
max-width: 11rem; max-width: 11rem;
} }
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
} }
.color-picker__top-picks { .color-picker__top-picks {
@@ -92,16 +80,6 @@
} }
} }
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;
@@ -163,15 +141,6 @@
width: 1.625rem; width: 1.625rem;
height: 1.625rem; height: 1.625rem;
} }
&.compact-sizing {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
}
&.mobile-border {
border: 1px solid var(--mobile-color-border);
}
} }
.color-picker__button__hotkey-label { .color-picker__button__hotkey-label {
@@ -1,12 +1,11 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useEffect } from "react"; import { useRef } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isTransparent, isTransparent,
isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
@@ -19,12 +18,7 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon, strokeIcon } from "../icons"; import { slashIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
@@ -73,7 +67,6 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null; palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple; topPicks?: ColorTuple;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
compactMode?: boolean;
} }
const ColorPickerPopupContent = ({ const ColorPickerPopupContent = ({
@@ -84,8 +77,6 @@ const ColorPickerPopupContent = ({
elements, elements,
palette = COLOR_PALETTE, palette = COLOR_PALETTE,
updateData, updateData,
getOpenPopup,
appState,
}: Pick< }: Pick<
ColorPickerProps, ColorPickerProps,
| "type" | "type"
@@ -95,10 +86,7 @@ const ColorPickerPopupContent = ({
| "elements" | "elements"
| "palette" | "palette"
| "updateData" | "updateData"
| "appState" >) => {
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
@@ -129,13 +117,9 @@ const ColorPickerPopupContent = ({
<PropertiesPopover <PropertiesPopover
container={container} container={container}
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
if (!isWritableElement(event.target)) { focusPickerContent();
focusPickerContent();
}
event.preventDefault(); event.preventDefault();
}} }}
onPointerDownOutside={(event) => { onPointerDownOutside={(event) => {
@@ -147,23 +131,8 @@ const ColorPickerPopupContent = ({
} }
}} }}
onClose={() => { onClose={() => {
// only clear if we're still the active popup (avoid racing with switch) updateData({ openPopup: null });
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
setActiveColorPickerSection(null); setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}} }}
> >
{palette ? ( {palette ? (
@@ -172,17 +141,7 @@ const ColorPickerPopupContent = ({
palette={palette} palette={palette}
color={color} color={color}
onChange={(changedColor) => { onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor); onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}} }}
onEyeDropperToggle={(force) => { onEyeDropperToggle={(force) => {
setEyeDropperState((state) => { setEyeDropperState((state) => {
@@ -209,18 +168,12 @@ const ColorPickerPopupContent = ({
if (eyeDropperState) { if (eyeDropperState) {
setEyeDropperState(null); setEyeDropperState(null);
} else { } else {
// close explicitly on Escape
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
}} }}
type={type} type={type}
elements={elements} elements={elements}
updateData={updateData} updateData={updateData}
showTitle={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
showHotKey={appState.stylesPanelMode !== "mobile"}
> >
{colorInputJSX} {colorInputJSX}
</Picker> </Picker>
@@ -235,32 +188,11 @@ const ColorPickerTrigger = ({
label, label,
color, color,
type, type,
stylesPanelMode,
mode = "background",
onToggle,
editingTextElement,
}: { }: {
color: string | null; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
stylesPanelMode?: AppState["stylesPanelMode"];
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => { }) => {
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
@@ -268,9 +200,6 @@ const ColorPickerTrigger = ({
"is-transparent": !color || color === "transparent", "is-transparent": !color || color === "transparent",
"has-outline": "has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing":
stylesPanelMode === "compact" || stylesPanelMode === "mobile",
"mobile-border": stylesPanelMode === "mobile",
})} })}
aria-label={label} aria-label={label}
style={color ? { "--swatch-color": color } : undefined} style={color ? { "--swatch-color": color } : undefined}
@@ -279,26 +208,8 @@ const ColorPickerTrigger = ({
? t("labels.showStroke") ? t("labels.showStroke")
: t("labels.showBackground") : t("labels.showBackground")
} }
data-openpopup={type}
onClick={handleClick}
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline">{!color && slashIcon}</div>
{(stylesPanelMode === "compact" || stylesPanelMode === "mobile") &&
color &&
mode === "stroke" && (
<div className="color-picker__button-background">
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
</div>
)}
</Popover.Trigger> </Popover.Trigger>
); );
}; };
@@ -314,62 +225,24 @@ export const ColorPicker = ({
updateData, updateData,
appState, appState,
}: ColorPickerProps) => { }: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
const compactMode =
type !== "canvasBackground" &&
(appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile");
return ( return (
<div> <div>
<div <div role="dialog" aria-modal="true" className="color-picker-container">
role="dialog" <TopPicks
aria-modal="true" activeColor={color}
className={clsx("color-picker-container", { onChange={onChange}
"color-picker-container--no-top-picks": compactMode, type={type}
})} topPicks={topPicks}
> />
{!compactMode && ( <ButtonSeparator />
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { updateData({ openPopup: open ? type : null });
updateData({ openPopup: type });
}
}} }}
> >
{/* serves as an active color indicator as well */} {/* serves as an active color indicator as well */}
<ColorPickerTrigger <ColorPickerTrigger color={color} label={label} type={type} />
color={color}
label={label}
type={type}
stylesPanelMode={appState.stylesPanelMode}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */} {/* popup content */}
{appState.openPopup === type && ( {appState.openPopup === type && (
<ColorPickerPopupContent <ColorPickerPopupContent
@@ -380,8 +253,6 @@ export const ColorPicker = ({
elements={elements} elements={elements}
palette={palette} palette={palette}
updateData={updateData} updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/> />
)} )}
</Popover.Root> </Popover.Root>
@@ -37,10 +37,8 @@ interface PickerProps {
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
children?: React.ReactNode; children?: React.ReactNode;
showTitle?: boolean;
onEyeDropperToggle: (force?: boolean) => void; onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
} }
export const Picker = React.forwardRef( export const Picker = React.forwardRef(
@@ -53,21 +51,11 @@ export const Picker = React.forwardRef(
palette, palette,
updateData, updateData,
children, children,
showTitle,
onEyeDropperToggle, onEyeDropperToggle,
onEscape, onEscape,
showHotKey = true,
}: PickerProps, }: PickerProps,
ref, ref,
) => { ) => {
const title = showTitle
? type === "elementStroke"
? t("labels.stroke")
: type === "elementBackground"
? t("labels.background")
: null
: null;
const [customColors] = React.useState(() => { const [customColors] = React.useState(() => {
if (type === "canvasBackground") { if (type === "canvasBackground") {
return []; return [];
@@ -166,8 +154,6 @@ export const Picker = React.forwardRef(
// to allow focusing by clicking but not by tabbing // to allow focusing by clicking but not by tabbing
tabIndex={-1} tabIndex={-1}
> >
{title && <div className="color-picker__title">{title}</div>}
{!!customColors.length && ( {!!customColors.length && (
<div> <div>
<PickerHeading> <PickerHeading>
@@ -189,18 +175,12 @@ export const Picker = React.forwardRef(
palette={palette} palette={palette}
onChange={onChange} onChange={onChange}
activeShade={activeShade} activeShade={activeShade}
showHotKey={showHotKey}
/> />
</div> </div>
<div> <div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading> <PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList <ShadeList color={color} onChange={onChange} palette={palette} />
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div> </div>
{children} {children}
</div> </div>
@@ -20,7 +20,6 @@ interface PickerColorListProps {
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
activeShade: number; activeShade: number;
showHotKey?: boolean;
} }
const PickerColorList = ({ const PickerColorList = ({
@@ -28,7 +27,6 @@ const PickerColorList = ({
color, color,
onChange, onChange,
activeShade, activeShade,
showHotKey = true,
}: PickerColorListProps) => { }: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color, color,
@@ -84,7 +82,7 @@ const PickerColorList = ({
key={key} key={key}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
{showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />} <HotkeyLabel color={color} keyLabel={keybinding} />
</button> </button>
); );
})} })}
@@ -16,15 +16,9 @@ interface ShadeListProps {
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
showHotKey?: boolean;
} }
export const ShadeList = ({ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent", color: color || "transparent",
palette, palette,
@@ -73,9 +67,7 @@ export const ShadeList = ({
}} }}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
{showHotKey && ( <HotkeyLabel color={color} keyLabel={i + 1} isShade />
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button> </button>
))} ))}
</div> </div>
@@ -100,19 +100,6 @@ $verticalBreakpoint: 861px;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
cursor: pointer; cursor: pointer;
--icon-size: 1rem;
&.command-item-large {
height: 2.75rem;
--icon-size: 1.5rem;
.icon {
width: var(--icon-size);
height: var(--icon-size);
margin-right: 0.625rem;
}
}
&:active { &:active {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
} }
@@ -143,17 +130,9 @@ $verticalBreakpoint: 861px;
} }
.icon { .icon {
width: var(--icon-size, 1rem); width: 16px;
height: var(--icon-size, 1rem); height: 16px;
margin-right: 0.375rem; margin-right: 6px;
.library-item-icon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
} }
} }
} }
@@ -1,19 +1,18 @@
import clsx from "clsx"; import clsx from "clsx";
import fuzzy from "fuzzy"; import fuzzy from "fuzzy";
import { useEffect, useRef, useMemo, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
EVENT, EVENT,
KEYS, KEYS,
capitalizeString, capitalizeString,
getShortcutKey,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch"; import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types"; import type { MarkRequired } from "@excalidraw/common/utility-types";
import { import {
@@ -62,21 +61,12 @@ import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify"; import { Ellipsify } from "../Ellipsify";
import {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
} from "../../data/library";
import {
useLibraryCache,
useLibraryItemSvg,
} from "../../hooks/useLibraryItemSvg";
import * as defaultItems from "./defaultCommandPaletteItems"; import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss"; import "./CommandPalette.scss";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types"; import type { AppProps, AppState, UIAppState } from "../../types";
import type { ShortcutName } from "../../actions/shortcuts"; import type { ShortcutName } from "../../actions/shortcuts";
import type { TranslationKeys } from "../../i18n"; import type { TranslationKeys } from "../../i18n";
import type { Action } from "../../actions/types"; import type { Action } from "../../actions/types";
@@ -90,7 +80,6 @@ export const DEFAULT_CATEGORIES = {
editor: "Editor", editor: "Editor",
elements: "Elements", elements: "Elements",
links: "Links", links: "Links",
library: "Library",
}; };
const getCategoryOrder = (category: string) => { const getCategoryOrder = (category: string) => {
@@ -218,34 +207,6 @@ function CommandPaletteInner({
appProps, appProps,
}); });
const [libraryItemsData] = useAtom(libraryItemsAtom);
const libraryCommands: CommandPaletteItem[] = useMemo(() => {
return (
libraryItemsData.libraryItems
?.filter(
(libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
!!libraryItem.name,
)
.map((libraryItem) => ({
label: libraryItem.name,
icon: (
<LibraryItemIcon
id={libraryItem.id}
elements={libraryItem.elements}
/>
),
category: "Library",
order: getCategoryOrder("Library"),
haystack: deburr(libraryItem.name),
perform: () => {
app.onInsertElements(
distributeLibraryItemsOnSquareGrid([libraryItem]),
);
},
})) || []
);
}, [app, libraryItemsData.libraryItems]);
useEffect(() => { useEffect(() => {
// these props change often and we don't want them to re-run the effect // these props change often and we don't want them to re-run the effect
// which would renew `allCommands`, cascading down and resetting state. // which would renew `allCommands`, cascading down and resetting state.
@@ -477,6 +438,7 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementStroke", openPopup: "elementStroke",
})); }));
}, },
@@ -496,6 +458,7 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementBackground", openPopup: "elementBackground",
})); }));
}, },
@@ -625,9 +588,8 @@ function CommandPaletteInner({
setAllCommands(allCommands); setAllCommands(allCommands);
setLastUsed( setLastUsed(
[...allCommands, ...libraryCommands].find( allCommands.find((command) => command.label === lastUsed?.label) ??
(command) => command.label === lastUsed?.label, null,
) ?? null,
); );
} }
}, [ }, [
@@ -638,7 +600,6 @@ function CommandPaletteInner({
lastUsed?.label, lastUsed?.label,
setLastUsed, setLastUsed,
setAppState, setAppState,
libraryCommands,
]); ]);
const [commandSearch, setCommandSearch] = useState(""); const [commandSearch, setCommandSearch] = useState("");
@@ -835,17 +796,9 @@ function CommandPaletteInner({
return nextCommandsByCategory; return nextCommandsByCategory;
}; };
let matchingCommands = let matchingCommands = allCommands
commandSearch?.length > 1 .filter(isCommandAvailable)
? [ .sort((a, b) => a.order - b.order);
...allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order),
...libraryCommands,
]
: allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order);
const showLastUsed = const showLastUsed =
!commandSearch && lastUsed && isCommandAvailable(lastUsed); !commandSearch && lastUsed && isCommandAvailable(lastUsed);
@@ -869,20 +822,14 @@ function CommandPaletteInner({
); );
matchingCommands = fuzzy matchingCommands = fuzzy
.filter(_query, matchingCommands, { .filter(_query, matchingCommands, {
extract: (command) => command.haystack ?? "", extract: (command) => command.haystack,
}) })
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.map((item) => item.original); .map((item) => item.original);
setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
setCurrentCommand(matchingCommands[0] ?? null); setCurrentCommand(matchingCommands[0] ?? null);
}, [ }, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
commandSearch,
allCommands,
isCommandAvailable,
lastUsed,
libraryCommands,
]);
return ( return (
<Dialog <Dialog
@@ -957,7 +904,6 @@ function CommandPaletteInner({
onMouseMove={() => setCurrentCommand(command)} onMouseMove={() => setCurrentCommand(command)}
showShortcut={!app.device.viewport.isMobile} showShortcut={!app.device.viewport.isMobile}
appState={uiAppState} appState={uiAppState}
size={category === "Library" ? "large" : "small"}
/> />
))} ))}
</div> </div>
@@ -973,20 +919,6 @@ function CommandPaletteInner({
</Dialog> </Dialog>
); );
} }
const LibraryItemIcon = ({
id,
elements,
}: {
id: LibraryItem["id"] | null;
elements: LibraryItem["elements"] | undefined;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const { svgCache } = useLibraryCache();
useLibraryItemSvg(id, elements, svgCache, ref);
return <div className="library-item-icon" ref={ref} />;
};
const CommandItem = ({ const CommandItem = ({
command, command,
@@ -996,7 +928,6 @@ const CommandItem = ({
onClick, onClick,
showShortcut, showShortcut,
appState, appState,
size = "small",
}: { }: {
command: CommandPaletteItem; command: CommandPaletteItem;
isSelected: boolean; isSelected: boolean;
@@ -1005,7 +936,6 @@ const CommandItem = ({
onClick: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void;
showShortcut: boolean; showShortcut: boolean;
appState: UIAppState; appState: UIAppState;
size?: "small" | "large";
}) => { }) => {
const noop = () => {}; const noop = () => {};
@@ -1014,7 +944,6 @@ const CommandItem = ({
className={clsx("command-item", { className={clsx("command-item", {
"item-selected": isSelected, "item-selected": isSelected,
"item-disabled": disabled, "item-disabled": disabled,
"command-item-large": size === "large",
})} })}
ref={(ref) => { ref={(ref) => {
if (isSelected && !disabled) { if (isSelected && !disabled) {
@@ -1030,8 +959,6 @@ const CommandItem = ({
<div className="name"> <div className="name">
{command.icon && ( {command.icon && (
<InlineIcon <InlineIcon
className="icon"
size="var(--icon-size, 1rem)"
icon={ icon={
typeof command.icon === "function" typeof command.icon === "function"
? command.icon(appState) ? command.icon(appState)
@@ -1,10 +1,6 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.context-menu-popover {
z-index: var(--zIndex-ui-context-menu);
}
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
@@ -64,7 +64,6 @@ export const ContextMenu = React.memo(
offsetTop={appState.offsetTop} offsetTop={appState.offsetTop}
viewportWidth={appState.width} viewportWidth={appState.width}
viewportHeight={appState.height} viewportHeight={appState.height}
className="context-menu-popover"
> >
<ul <ul
className="context-menu" className="context-menu"
@@ -1,8 +1,5 @@
.excalidraw { .excalidraw {
.ExcalidrawLogo { .ExcalidrawLogo {
--logo-icon--mobile: 1rem;
--logo-text--mobile: 0.75rem;
--logo-icon--xs: 2rem; --logo-icon--xs: 2rem;
--logo-text--xs: 1.5rem; --logo-text--xs: 1.5rem;
@@ -33,17 +30,6 @@
color: var(--color-logo-text); color: var(--color-logo-text);
} }
&.is-mobile {
.ExcalidrawLogo-icon {
height: var(--logo-icon--mobile);
}
.ExcalidrawLogo-text {
height: var(--logo-text--mobile);
margin-left: 0.5rem;
}
}
&.is-xs { &.is-xs {
.ExcalidrawLogo-icon { .ExcalidrawLogo-icon {
height: var(--logo-icon--xs); height: var(--logo-icon--xs);
@@ -41,7 +41,7 @@ const LogoText = () => (
</svg> </svg>
); );
type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile"; type LogoSize = "xs" | "small" | "normal" | "large" | "custom";
interface LogoProps { interface LogoProps {
size?: LogoSize; size?: LogoSize;
@@ -11,10 +11,5 @@
2rem + 4 * var(--default-button-size) 2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons ); // 4 gaps + 4 buttons
} }
&--compact {
display: block;
grid-template-columns: none;
}
} }
} }
@@ -1,5 +1,4 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
@@ -59,7 +58,6 @@ interface FontPickerProps {
onHover: (fontFamily: FontFamilyValues) => void; onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void; onLeave: () => void;
onPopupChange: (open: boolean) => void; onPopupChange: (open: boolean) => void;
compactMode?: boolean;
} }
export const FontPicker = React.memo( export const FontPicker = React.memo(
@@ -71,7 +69,6 @@ export const FontPicker = React.memo(
onHover, onHover,
onLeave, onLeave,
onPopupChange, onPopupChange,
compactMode = false,
}: FontPickerProps) => { }: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback( const onSelectCallback = useCallback(
@@ -84,30 +81,18 @@ export const FontPicker = React.memo(
); );
return ( return (
<div <div role="dialog" aria-modal="true" className="FontPicker__container">
role="dialog" <div className="buttonList">
aria-modal="true" <RadioSelection<FontFamilyValues | false>
className={clsx("FontPicker__container", { type="button"
"FontPicker__container--compact": compactMode, options={defaultFonts}
})} value={selectedFontFamily}
> onClick={onSelectCallback}
{!compactMode && (
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
compactMode={compactMode}
/> />
</div>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
@@ -90,8 +90,7 @@ export const FontPickerList = React.memo(
onClose, onClose,
}: FontPickerListProps) => { }: FontPickerListProps) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const app = useApp(); const { fonts } = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -188,42 +187,6 @@ export const FontPickerList = React.memo(
onLeave, onLeave,
]); ]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>( const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => { (event) => {
const handled = fontPickerKeyHandler({ const handled = fontPickerKeyHandler({
@@ -231,7 +194,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect: wrappedOnSelect, onSelect,
onHover, onHover,
onClose, onClose,
}); });
@@ -241,7 +204,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], [hoveredFont, filteredFonts, onSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@@ -277,7 +240,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font // allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1} tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => { onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value)); onSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@@ -319,32 +282,15 @@ export const FontPickerList = React.memo(
className="properties-content" className="properties-content"
container={container} container={container}
style={{ width: "15rem" }} style={{ width: "15rem" }}
onClose={() => { onClose={onClose}
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave} onPointerLeave={onLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
{app.state.stylesPanelMode === "full" && ( <QuickSearch
<QuickSearch ref={inputRef}
ref={inputRef} placeholder={t("quickSearch.placeholder")}
placeholder={t("quickSearch.placeholder")} onChange={debounce(setSearchTerm, 20)}
onChange={debounce(setSearchTerm, 20)} />
/>
)}
<ScrollableList <ScrollableList
className="dropdown-menu fonts manual-hover" className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")} placeholder={t("fontList.empty")}
@@ -1,6 +1,5 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types"; import type { FontFamilyValues } from "@excalidraw/element/types";
@@ -8,49 +7,33 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon"; import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons"; import { TextIcon } from "../icons";
import { useExcalidrawSetAppState } from "../App"; import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
compactMode?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
isOpened = false,
compactMode = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState(); const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
const compactStyle = compactMode [selectedFontFamily],
? { );
...MOBILE_ACTION_BUTTON_BG,
width: "2rem",
height: "2rem",
}
: {};
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger"> {/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon <ButtonIcon
standalone standalone
icon={TextIcon} icon={TextIcon}
title={t("labels.showFonts")} title={t("labels.showFonts")}
className="properties-trigger" className="properties-trigger"
testId={"font-family-show-fonts"} testId={"font-family-show-fonts"}
active={isOpened} active={isTriggerActive}
onClick={() => { // no-op
setAppState((appState) => ({ onClick={() => {}}
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
...compactStyle,
}}
/> />
</div> </div>
</Popover.Trigger> </Popover.Trigger>
@@ -18,7 +18,7 @@ type LockIconProps = {
export const HandButton = (props: LockIconProps) => { export const HandButton = (props: LockIconProps) => {
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable: false, active: props.checked })} className={clsx("Shape", { fillable: false })}
type="radio" type="radio"
icon={handIcon} icon={handIcon}
name="editor-current-shape" name="editor-current-shape"
@@ -2,12 +2,11 @@ import React from "react";
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common"; import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS } from "@excalidraw/common"; import { KEYS, getShortcutKey } from "@excalidraw/common";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
@@ -28,24 +28,11 @@ $wide-viewport-width: 1000px;
> span { > span {
padding: 0.25rem; padding: 0.25rem;
} }
kbd {
display: inline-block;
margin: 0 1px;
font-family: monospace;
border: 1px solid var(--color-gray-40);
border-radius: 4px;
padding: 1px 3px;
font-size: 10px;
}
} }
&.theme--dark { &.theme--dark {
.HintViewer { .HintViewer {
color: var(--color-gray-60); color: var(--color-gray-60);
kbd {
border-color: var(--color-gray-60);
}
} }
} }
} }
+31 -93
View File
@@ -9,10 +9,11 @@ import {
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getShortcutKey } from "@excalidraw/common";
import { isNodeInFlowchart } from "@excalidraw/element"; import { isNodeInFlowchart } from "@excalidraw/element";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping"; import { isGridModeEnabled } from "../snapping";
@@ -27,11 +28,6 @@ interface HintViewerProps {
app: AppClassProperties; app: AppClassProperties;
} }
const getTaggedShortcutKey = (key: string | string[]) =>
Array.isArray(key)
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
: `<kbd>${getShortcutKey(key)}</kbd>`;
const getHints = ({ const getHints = ({
appState, appState,
isMobile, isMobile,
@@ -46,9 +42,7 @@ const getHints = ({
appState.openSidebar.tab === CANVAS_SEARCH_TAB && appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.matches.length appState.searchMatches?.matches.length
) { ) {
return t("hints.dismissSearch", { return t("hints.dismissSearch");
shortcut: getTaggedShortcutKey("Escape"),
});
} }
if (appState.openSidebar && !device.editor.canFitSidebar) { if (appState.openSidebar && !device.editor.canFitSidebar) {
@@ -56,21 +50,14 @@ const getHints = ({
} }
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
return t("hints.eraserRevert", { return t("hints.eraserRevert");
shortcut: getTaggedShortcutKey("Alt"),
});
} }
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) { if (multiMode) {
return t("hints.linearElementMulti", { return t("hints.linearElementMulti");
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey("Enter"),
});
} }
if (activeTool.type === "arrow") { if (activeTool.type === "arrow") {
return t("hints.arrowTool", { return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
shortcut: getTaggedShortcutKey("A"),
});
} }
return t("hints.linearElement"); return t("hints.linearElement");
} }
@@ -96,51 +83,31 @@ const getHints = ({
) { ) {
const targetElement = selectedElements[0]; const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length === 2) { if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle", { return t("hints.lockAngle");
shortcut: getTaggedShortcutKey("Shift"),
});
} }
return isImageElement(targetElement) return isImageElement(targetElement)
? t("hints.resizeImage", { ? t("hints.resizeImage")
shortcut_1: getTaggedShortcutKey("Shift"), : t("hints.resize");
shortcut_2: getTaggedShortcutKey("Alt"),
})
: t("hints.resize", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
} }
if (isRotating && lastPointerDownWith === "mouse") { if (isRotating && lastPointerDownWith === "mouse") {
return t("hints.rotate", { return t("hints.rotate");
shortcut: getTaggedShortcutKey("Shift"),
});
} }
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected", { return t("hints.text_selected");
shortcut: getTaggedShortcutKey("Enter"),
});
} }
if (appState.editingTextElement) { if (appState.editingTextElement) {
return t("hints.text_editing", { return t("hints.text_editing");
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
} }
if (appState.croppingElementId) { if (appState.croppingElementId) {
return t("hints.leaveCropEditor", { return t("hints.leaveCropEditor");
shortcut_1: getTaggedShortcutKey("Enter"),
shortcut_2: getTaggedShortcutKey("Escape"),
});
} }
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) { if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor", { return t("hints.enterCropEditor");
shortcut: getTaggedShortcutKey("Enter"),
});
} }
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
@@ -150,57 +117,33 @@ const getHints = ({
!appState.editingTextElement && !appState.editingTextElement &&
!appState.selectedLinearElement?.isEditing !appState.selectedLinearElement?.isEditing
) { ) {
return t("hints.deepBoxSelect", { return [t("hints.deepBoxSelect")];
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
} }
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping", { return t("hints.disableSnapping");
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
} }
if (!selectedElements.length && !isMobile) { if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning", { return [t("hints.canvasPanning")];
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
shortcut_2: getTaggedShortcutKey("Space"),
});
} }
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) { if (isLinearElement(selectedElements[0])) {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
return appState.selectedLinearElement.selectedPointsIndices return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected", { ? t("hints.lineEditor_pointSelected")
shortcut_1: getTaggedShortcutKey("Delete"), : t("hints.lineEditor_nothingSelected");
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
})
: t("hints.lineEditor_nothingSelected", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
} }
return isLineElement(selectedElements[0]) return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info", { ? t("hints.lineEditor_line_info")
shortcut: getTaggedShortcutKey("Enter"), : t("hints.lineEditor_info");
})
: t("hints.lineEditor_info", {
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
} }
if ( if (
!appState.newElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0]) isTextBindableContainer(selectedElements[0])
) { ) {
const bindTextToElement = t("hints.bindTextToElement", {
shortcut: getTaggedShortcutKey("Enter"),
});
const createFlowchart = t("hints.createFlowchart", {
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
});
if (isFlowchartNodeElement(selectedElements[0])) { if (isFlowchartNodeElement(selectedElements[0])) {
if ( if (
isNodeInFlowchart( isNodeInFlowchart(
@@ -208,13 +151,13 @@ const getHints = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
) )
) { ) {
return [bindTextToElement, createFlowchart]; return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
} }
return [bindTextToElement, createFlowchart]; return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
} }
return bindTextToElement; return t("hints.bindTextToElement");
} }
} }
} }
@@ -240,21 +183,16 @@ export const HintViewer = ({
} }
const hint = Array.isArray(hints) const hint = Array.isArray(hints)
? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ") ? hints
: hints; .map((hint) => {
return getShortcutKey(hint).replace(/\. ?$/, "");
const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => { })
if (index % 2 === 1) { .join(". ")
const shortcutMatch = : getShortcutKey(hints);
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
}
return part;
});
return ( return (
<div className="HintViewer"> <div className="HintViewer">
<span>{hintJSX}</span> <span>{hint}</span>
</div> </div>
); );
}; };
@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useDevice, useExcalidrawContainer } from "./App"; import { useDevice } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
@@ -39,7 +39,6 @@ function Picker<T>({
numberOfOptionsToAlwaysShow?: number; numberOfOptionsToAlwaysShow?: number;
}) { }) {
const device = useDevice(); const device = useDevice();
const { container } = useExcalidrawContainer();
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = options.find(
@@ -153,16 +152,17 @@ function Picker<T>({
); );
}; };
const isMobile = device.editor.isMobile;
return ( return (
<Popover.Content <Popover.Content
side={isMobile ? "right" : "bottom"} side={
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start" align="start"
sideOffset={isMobile ? 8 : 12} sideOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }} style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
> >
<div <div
className={`picker`} className={`picker`}
+3 -13
View File
@@ -1,20 +1,10 @@
export const InlineIcon = ({ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
className,
icon,
size = "1em",
}: {
className?: string;
icon: React.ReactNode;
size?: string;
}) => {
return ( return (
<span <span
className={className}
style={{ style={{
width: size, width: "1em",
height: "100%",
margin: "0 0.5ex 0 0.5ex", margin: "0 0.5ex 0 0.5ex",
display: "inline-flex", display: "inline-block",
lineHeight: 0, lineHeight: 0,
verticalAlign: "middle", verticalAlign: "middle",
flex: "0 0 auto", flex: "0 0 auto",
@@ -24,10 +24,6 @@
gap: 0.75rem; gap: 0.75rem;
pointer-events: none !important; pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * { & > * {
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
} }
+37 -101
View File
@@ -4,7 +4,6 @@ import React from "react";
import { import {
CLASSES, CLASSES,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE, TOOL_TYPE,
arrayToMap, arrayToMap,
capitalizeString, capitalizeString,
@@ -29,11 +28,7 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
SelectedShapeActions,
ShapesSwitcher,
CompactShapeActions,
} from "./Actions";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
@@ -91,7 +86,6 @@ interface LayerUIProps {
onPenModeToggle: AppClassProperties["togglePenMode"]; onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
@@ -150,7 +144,6 @@ const LayerUI = ({
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
showExitZenModeBtn, showExitZenModeBtn,
renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
UIOptions, UIOptions,
@@ -164,25 +157,6 @@ const LayerUI = ({
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const spacing =
appState.stylesPanelMode === "compact"
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@@ -235,55 +209,31 @@ const LayerUI = ({
</div> </div>
); );
const renderSelectedShapeActions = () => { const renderSelectedShapeActions = () => (
const isCompactMode = appState.stylesPanelMode === "compact"; <Section
heading="selectedShapeActions"
return ( className={clsx("selected-shape-actions zen-mode-transition", {
<Section "transition-left": appState.zenModeEnabled,
heading="selectedShapeActions" })}
className={clsx("selected-shape-actions zen-mode-transition", { >
"transition-left": appState.zenModeEnabled, <Island
})} className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
> >
{isCompactMode ? ( <SelectedShapeActions
<Island appState={appState}
className={clsx("compact-shape-actions-island")} elementsMap={app.scene.getNonDeletedElementsMap()}
padding={0} renderAction={actionManager.renderAction}
style={{ app={app}
// we want to make sure this doesn't overflow so subtracting the />
// approximate height of hamburgerMenu + footer </Island>
maxHeight: `${appState.height - 166}px`, </Section>
}} );
>
<CompactShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
)}
</Section>
);
};
const renderFixedSideContainer = () => { const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions( const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@@ -300,19 +250,9 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()} {renderCanvasActions()}
<div {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
appState.stylesPanelMode === "compact",
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && ( appState.openDialog?.name !== "elementLinkSelector" && (
@@ -322,19 +262,17 @@ const LayerUI = ({
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={spacing.toolbarColGap} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={spacing.toolbarRowGap} gap={1}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<Island <Island
padding={spacing.islandPadding} padding={1}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
"App-toolbar--compact":
appState.stylesPanelMode === "compact",
})} })}
> >
<HintViewer <HintViewer
@@ -344,7 +282,7 @@ const LayerUI = ({
app={app} app={app}
/> />
{heading} {heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}> <Stack.Row gap={1}>
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
@@ -368,7 +306,7 @@ const LayerUI = ({
/> />
<ShapesSwitcher <ShapesSwitcher
setAppState={setAppState} appState={appState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions} UIOptions={UIOptions}
app={app} app={app}
@@ -378,7 +316,7 @@ const LayerUI = ({
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
marginLeft: spacing.collabMarginLeft, marginLeft: 8,
alignSelf: "center", alignSelf: "center",
height: "fit-content", height: "fit-content",
}} }}
@@ -406,8 +344,6 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact":
appState.stylesPanelMode === "compact",
}, },
)} )}
> >
@@ -482,9 +418,7 @@ const LayerUI = ({
}} }}
tab={DEFAULT_SIDEBAR.defaultTab} tab={DEFAULT_SIDEBAR.defaultTab}
> >
{appState.stylesPanelMode === "full" && {t("toolBar.library")}
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
t("toolBar.library")}
</DefaultSidebar.Trigger> </DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog /> <DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />} {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
@@ -584,11 +518,13 @@ const LayerUI = ({
renderJSONExportDialog={renderJSONExportDialog} renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle} onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars} renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />
@@ -133,10 +133,15 @@
} }
.layer-ui__library .library-menu-dropdown-container { .layer-ui__library .library-menu-dropdown-container {
z-index: 1;
position: relative; position: relative;
&--in-heading { &--in-heading {
margin-left: auto; padding: 0;
position: absolute;
top: 1rem;
right: 0.75rem;
z-index: 1;
.dropdown-menu { .dropdown-menu {
top: 100%; top: 100%;
} }
+1 -47
View File
@@ -11,11 +11,6 @@ import {
LIBRARY_DISABLED_TYPES, LIBRARY_DISABLED_TYPES,
randomId, randomId,
isShallowEqual, isShallowEqual,
KEYS,
isWritableElement,
addEventListener,
EVENT,
CLASSES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -271,52 +266,11 @@ export const LibraryMenu = memo(() => {
const memoizedLibrary = useMemo(() => app.library, [app.library]); const memoizedLibrary = useMemo(() => app.library, [app.library]);
const pendingElements = usePendingElementsMemo(appState, app); const pendingElements = usePendingElementsMemo(appState, app);
useEffect(() => {
return addEventListener(
document,
EVENT.KEYDOWN,
(event) => {
if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) {
const target = event.target;
if (target.closest(`.${CLASSES.SIDEBAR}`)) {
// stop propagation so that we don't prevent it downstream
// (default browser behavior is to clear search input on ESC)
if (selectedItems.length > 0) {
event.stopPropagation();
setSelectedItems([]);
} else if (
isWritableElement(target) &&
target instanceof HTMLInputElement &&
!target.value
) {
event.stopPropagation();
// if search input empty -> close library
// (maybe not a good idea?)
setAppState({ openSidebar: null });
app.focusContainer();
}
} else if (selectedItems.length > 0) {
const { x, y } = app.lastViewportPosition;
const elementUnderCursor = document.elementFromPoint(x, y);
// also deselect elements if sidebar doesn't have focus but the
// cursor is over it
if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) {
event.stopPropagation();
setSelectedItems([]);
}
}
}
},
{ capture: true },
);
}, [selectedItems, setAppState, app]);
const onInsertLibraryItems = useCallback( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
app.focusContainer();
}, },
[onInsertElements, app], [onInsertElements],
); );
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
@@ -220,6 +220,14 @@ export const LibraryDropdownMenuButton: React.FC<{
{t("buttons.export")} {t("buttons.export")}
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
{itemsSelected && ( {itemsSelected && (
<DropdownMenu.Item <DropdownMenu.Item
icon={publishIcon} icon={publishIcon}
@@ -229,14 +237,6 @@ export const LibraryDropdownMenuButton: React.FC<{
{t("buttons.publishLibrary")} {t("buttons.publishLibrary")}
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
); );
@@ -1,42 +1,24 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
--container-padding-y: 1rem; --container-padding-y: 1.5rem;
--container-padding-x: 0.75rem; --container-padding-x: 0.75rem;
.library-menu-items-header {
display: flex;
padding-top: 1rem;
padding-bottom: 0.5rem;
gap: 0.5rem;
}
.library-menu-items__no-items { .library-menu-items__no-items {
text-align: center; text-align: center;
color: var(--color-gray-70); color: var(--color-gray-70);
line-height: 1.5; line-height: 1.5;
font-size: 0.875rem; font-size: 0.875rem;
width: 100%; width: 100%;
min-height: 55px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&__label { &__label {
color: var(--color-primary); color: var(--color-primary);
font-weight: 700; font-weight: 700;
font-size: 1.125rem; font-size: 1.125rem;
margin-bottom: 0.25rem; margin-bottom: 0.75rem;
} }
} }
.library-menu-items__no-items__hint {
color: var(--color-border-outline);
padding: 0.75rem 1rem;
}
&.theme--dark { &.theme--dark {
.library-menu-items__no-items { .library-menu-items__no-items {
color: var(--color-gray-40); color: var(--color-gray-40);
@@ -52,7 +34,7 @@
overflow-y: auto; overflow-y: auto;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
justify-content: flex-start; justify-content: center;
margin: 0; margin: 0;
position: relative; position: relative;
@@ -69,45 +51,26 @@
} }
&__items { &__items {
// so that spinner is relative-positioned to this container
position: relative;
row-gap: 0.5rem; row-gap: 0.5rem;
padding: 1rem 0 var(--container-padding-y) 0; padding: var(--container-padding-y) 0;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
margin-bottom: 1rem;
} }
&__header { &__header {
display: flex;
align-items: center;
flex: 1 1 auto;
color: var(--color-primary); color: var(--color-primary);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
width: 100%; width: 100%;
padding-right: 4rem; // due to dropdown button
box-sizing: border-box; box-sizing: border-box;
&--excal { &--excal {
margin-top: 2rem; margin-top: 2rem;
} }
&__hint {
margin-left: auto;
font-size: 10px;
color: var(--color-border-outline);
font-weight: 400;
kbd {
font-family: monospace;
border: 1px solid var(--color-border-outline);
border-radius: 4px;
padding: 1px 3px;
}
}
} }
&__grid { &__grid {
@@ -116,24 +79,6 @@
grid-gap: 1rem; grid-gap: 1rem;
} }
&__search {
flex: 1 1 auto;
margin: 0;
.ExcTextField__input {
height: var(--lg-button-size);
input {
font-size: 0.875rem;
}
}
&.hideCancelButton input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
display: none;
}
}
.separator { .separator {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -6,14 +6,11 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common"; import { MIME_TYPES, arrayToMap } from "@excalidraw/common";
import { duplicateElements } from "@excalidraw/element"; import { duplicateElements } from "@excalidraw/element";
import clsx from "clsx"; import { serializeLibraryAsJSON } from "../data/json";
import { deburr } from "../deburr";
import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import { useScrollPosition } from "../hooks/useScrollPosition"; import { useScrollPosition } from "../hooks/useScrollPosition";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -30,14 +27,6 @@ import Stack from "./Stack";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { TextField } from "./TextField";
import { useDevice } from "./App";
import { Button } from "./Button";
import type { ExcalidrawLibraryIds } from "../data/types";
import type { import type {
ExcalidrawProps, ExcalidrawProps,
LibraryItem, LibraryItem,
@@ -75,7 +64,6 @@ export default function LibraryMenuItems({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) { }) {
const device = useDevice();
const libraryContainerRef = useRef<HTMLDivElement>(null); const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef); const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
@@ -87,30 +75,6 @@ export default function LibraryMenuItems({
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const { svgCache } = useLibraryCache(); const { svgCache } = useLibraryCache();
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const [searchInputValue, setSearchInputValue] = useState("");
const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length;
const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim();
const filteredItems = useMemo(() => {
const searchQuery = deburr(searchInputValue.trim().toLowerCase());
if (!searchQuery) {
return [];
}
return libraryItems.filter((item) => {
const itemName = item.name || "";
return (
itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery)
);
});
}, [libraryItems, searchInputValue]);
const unpublishedItems = useMemo( const unpublishedItems = useMemo(
() => libraryItems.filter((item) => item.status !== "published"), () => libraryItems.filter((item) => item.status !== "published"),
[libraryItems], [libraryItems],
@@ -121,10 +85,23 @@ export default function LibraryMenuItems({
[libraryItems], [libraryItems],
); );
const showBtn = !libraryItems.length && !pendingElements.length;
const isLibraryEmpty =
!pendingElements.length &&
!unpublishedItems.length &&
!publishedItems.length;
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const onItemSelectToggle = useCallback( const onItemSelectToggle = useCallback(
(id: LibraryItem["id"], event: React.MouseEvent) => { (id: LibraryItem["id"], event: React.MouseEvent) => {
const shouldSelect = !selectedItems.includes(id); const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems]; const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) { if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) { if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex( const rangeStart = orderedItems.findIndex(
@@ -138,13 +115,10 @@ export default function LibraryMenuItems({
} }
const selectedItemsMap = arrayToMap(selectedItems); const selectedItemsMap = arrayToMap(selectedItems);
// Support both top-down and bottom-up selection by using min/max
const minRange = Math.min(rangeStart, rangeEnd);
const maxRange = Math.max(rangeStart, rangeEnd);
const nextSelectedIds = orderedItems.reduce( const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => { (acc: LibraryItem["id"][], item, idx) => {
if ( if (
(idx >= minRange && idx <= maxRange) || (idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id) selectedItemsMap.has(item.id)
) { ) {
acc.push(item.id); acc.push(item.id);
@@ -153,6 +127,7 @@ export default function LibraryMenuItems({
}, },
[], [],
); );
onSelectItems(nextSelectedIds); onSelectItems(nextSelectedIds);
} else { } else {
onSelectItems([...selectedItems, id]); onSelectItems([...selectedItems, id]);
@@ -172,14 +147,6 @@ export default function LibraryMenuItems({
], ],
); );
useEffect(() => {
// if selection is removed (e.g. via esc), reset last selected item
// so that subsequent shift+clicks don't select a large range
if (!selectedItems.length) {
setLastSelectedItem(null);
}
}, [selectedItems]);
const getInsertedElements = useCallback( const getInsertedElements = useCallback(
(id: string) => { (id: string) => {
let targetElements; let targetElements;
@@ -208,17 +175,12 @@ export default function LibraryMenuItems({
const onItemDrag = useCallback( const onItemDrag = useCallback(
(id: LibraryItem["id"], event: React.DragEvent) => { (id: LibraryItem["id"], event: React.DragEvent) => {
// we want to serialize just the ids so the operation is fast and there's
// no race condition if people drop the library items on canvas too fast
const data: ExcalidrawLibraryIds = {
itemIds: selectedItems.includes(id) ? selectedItems : [id],
};
event.dataTransfer.setData( event.dataTransfer.setData(
MIME_TYPES.excalidrawlibIds, MIME_TYPES.excalidrawlib,
JSON.stringify(data), serializeLibraryAsJSON(getInsertedElements(id)),
); );
}, },
[selectedItems], [getInsertedElements],
); );
const isItemSelected = useCallback( const isItemSelected = useCallback(
@@ -226,6 +188,7 @@ export default function LibraryMenuItems({
if (!id) { if (!id) {
return false; return false;
} }
return selectedItems.includes(id); return selectedItems.includes(id);
}, },
[selectedItems], [selectedItems],
@@ -245,136 +208,10 @@ export default function LibraryMenuItems({
); );
const itemsRenderedPerBatch = const itemsRenderedPerBatch =
svgCache.size >= svgCache.size >= libraryItems.length
(filteredItems.length ? filteredItems : libraryItems).length
? CACHED_ITEMS_RENDERED_PER_BATCH ? CACHED_ITEMS_RENDERED_PER_BATCH
: ITEMS_RENDERED_PER_BATCH; : ITEMS_RENDERED_PER_BATCH;
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// focus could be stolen by tab trigger button
nextAnimationFrame(() => {
searchInputRef.current?.focus();
});
}, []);
const JSX_whenNotSearching = !IS_SEARCHING && (
<>
{!IS_LIBRARY_EMPTY && (
<div className="library-menu-items-container__header">
{t("labels.personalLib")}
</div>
)}
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
{!publishedItems.length && (
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
)}
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
{publishedItems.length > 0 && (
<div
className="library-menu-items-container__header"
style={{ marginTop: "0.75rem" }}
>
{t("labels.excalidrawLib")}
</div>
)}
{publishedItems.length > 0 && (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</>
);
const JSX_whenSearching = IS_SEARCHING && (
<>
<div className="library-menu-items-container__header">
{t("library.search.heading")}
{!isLoading && (
<div
className="library-menu-items-container__header__hint"
style={{ cursor: "pointer" }}
onPointerDown={(e) => e.preventDefault()}
onClick={(event) => {
setSearchInputValue("");
}}
>
<kbd>esc</kbd> to clear
</div>
)}
</div>
{filteredItems.length > 0 ? (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={filteredItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__hint">
{t("library.search.noResults")}
</div>
<Button
onPointerDown={(e) => e.preventDefault()}
onSelect={() => {
setSearchInputValue("");
}}
style={{ width: "auto", marginTop: "1rem" }}
>
{t("library.search.clearSearch")}
</Button>
</div>
)}
</>
);
return ( return (
<div <div
className="library-menu-items-container" className="library-menu-items-container"
@@ -386,58 +223,127 @@ export default function LibraryMenuItems({
: { borderBottom: 0 } : { borderBottom: 0 }
} }
> >
<div className="library-menu-items-header"> {!isLibraryEmpty && (
{!IS_LIBRARY_EMPTY && (
<TextField
ref={searchInputRef}
type="search"
className={clsx("library-menu-items-container__search", {
hideCancelButton: !device.editor.isMobile,
})}
placeholder={t("library.search.inputPlaceholder")}
value={searchInputValue}
onChange={(value) => setSearchInputValue(value)}
/>
)}
<LibraryDropdownMenu <LibraryDropdownMenu
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={onSelectItems} onSelectItems={onSelectItems}
className="library-menu-dropdown-container--in-heading" className="library-menu-dropdown-container--in-heading"
/> />
</div> )}
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
align="start" align="start"
gap={1} gap={1}
style={{ style={{
flex: publishedItems.length > 0 ? 1 : "0 1 auto", flex: publishedItems.length > 0 ? 1 : "0 1 auto",
margin: IS_LIBRARY_EMPTY ? "auto" : 0, marginBottom: 0,
}} }}
ref={libraryContainerRef} ref={libraryContainerRef}
> >
{isLoading && ( <>
<div {!isLibraryEmpty && (
style={{ <div className="library-menu-items-container__header">
position: "absolute", {t("labels.personalLib")}
top: "var(--container-padding-y)", </div>
right: "var(--container-padding-x)", )}
transform: "translateY(50%)", {isLoading && (
}} <div
> style={{
<Spinner /> position: "absolute",
</div> top: "var(--container-padding-y)",
)} right: "var(--container-padding-x)",
transform: "translateY(50%)",
}}
>
<Spinner />
</div>
)}
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</>
{JSX_whenNotSearching} <>
{JSX_whenSearching} {(publishedItems.length > 0 ||
pendingElements.length > 0 ||
unpublishedItems.length > 0) && (
<div className="library-menu-items-container__header library-menu-items-container__header--excal">
{t("labels.excalidrawLib")}
</div>
)}
{publishedItems.length > 0 ? (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : unpublishedItems.length > 0 ? (
<div
style={{
margin: "1rem 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
</div>
) : null}
</>
{IS_LIBRARY_EMPTY && ( {showBtn && (
<LibraryMenuControlButtons <LibraryMenuControlButtons
style={{ padding: "16px 0", width: "100%" }} style={{ padding: "16px 0", width: "100%" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
/> >
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/>
</LibraryMenuControlButtons>
)} )}
</Stack.Col> </Stack.Col>
</div> </div>
@@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
type LibraryOrPendingItem = readonly ( type LibraryOrPendingItem = (
| LibraryItem | LibraryItem
| /* pending library item */ { | /* pending library item */ {
id: null; id: null;
@@ -18,12 +18,12 @@
} }
&--hover { &--hover {
background-color: var(--color-surface-mid); border-color: var(--color-primary);
} }
&:active:not(:has(.library-unit__checkbox:hover)),
&--selected { &--selected {
background-color: var(--color-surface-high); border-color: var(--color-primary);
border-width: 1px;
} }
&--skeleton { &--skeleton {
+18 -2
View File
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { memo, useRef, useState } from "react"; import { memo, useEffect, useRef, useState } from "react";
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
@@ -33,7 +33,23 @@ export const LibraryUnit = memo(
svgCache: SvgCache; svgCache: SvgCache;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const svg = useLibraryItemSvg(id, elements, svgCache, ref); const svg = useLibraryItemSvg(id, elements, svgCache);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
if (svg) {
node.innerHTML = svg.outerHTML;
}
return () => {
node.innerHTML = "";
};
}, [svg]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().editor.isMobile; const isMobile = useDevice().editor.isMobile;
+132 -77
View File
@@ -1,23 +1,32 @@
import React from "react"; import React from "react";
import { showSelectedShapeActions } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { MobileShapeActions } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { MobileToolBar } from "./MobileToolBar";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { HandButton } from "./HandButton";
import { HintViewer } from "./HintViewer";
import { Island } from "./Island"; import { Island } from "./Island";
import { LockButton } from "./LockButton";
import { PenModeButton } from "./PenModeButton";
import { Section } from "./Section";
import Stack from "./Stack";
import type { ActionManager } from "../actions/manager"; import type { ActionManager } from "../actions/manager";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
AppState, AppState,
Device,
ExcalidrawProps,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { JSX } from "react"; import type { JSX } from "react";
@@ -29,6 +38,7 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"]; onPenModeToggle: AppClassProperties["togglePenMode"];
@@ -36,11 +46,9 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: UIAppState, appState: UIAppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderTopLeftUI?: ( renderCustomStats?: ExcalidrawProps["renderCustomStats"];
isMobile: boolean,
appState: UIAppState,
) => JSX.Element | null;
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
app: AppClassProperties; app: AppClassProperties;
@@ -51,10 +59,14 @@ export const MobileMenu = ({
elements, elements,
actionManager, actionManager,
setAppState, setAppState,
onLockToggle,
onHandToolToggle, onHandToolToggle,
renderTopLeftUI, onPenModeToggle,
renderTopRightUI, renderTopRightUI,
renderCustomStats,
renderSidebars, renderSidebars,
device,
renderWelcomeScreen, renderWelcomeScreen,
UIOptions, UIOptions,
app, app,
@@ -64,98 +76,141 @@ export const MobileMenu = ({
MainMenuTunnel, MainMenuTunnel,
DefaultSidebarTriggerTunnel, DefaultSidebarTriggerTunnel,
} = useTunnels(); } = useTunnels();
const renderAppTopBar = () => { const renderToolbar = () => {
const topRightUI = renderTopRightUI?.(true, appState) ?? ( return (
<DefaultSidebarTriggerTunnel.Out /> <FixedSideContainer side="top" className="App-top-bar">
); {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
<Section heading="shapes">
const topLeftUI = ( {(heading: React.ReactNode) => (
<div className="excalidraw-ui-top-left"> <Stack.Col gap={4} align="center">
{renderTopLeftUI?.(true, appState)} <Stack.Row gap={1} className="App-toolbar-container">
<MainMenuTunnel.Out /> <Island padding={1} className="App-toolbar App-toolbar--mobile">
</div> {heading}
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<DefaultSidebarTriggerTunnel.Out />
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
</div>
</Stack.Row>
</Stack.Col>
)}
</Section>
<HintViewer
appState={appState}
isMobile={true}
device={device}
app={app}
/>
</FixedSideContainer>
); );
};
const renderAppToolbar = () => {
if ( if (
appState.viewModeEnabled || appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector" appState.openDialog?.name === "elementLinkSelector"
) { ) {
return <div className="App-toolbar-content">{topLeftUI}</div>; return (
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
</div>
);
} }
return ( return (
<div <div className="App-toolbar-content">
className="App-toolbar-content" <MainMenuTunnel.Out />
style={{ {actionManager.renderAction("toggleEditMenu")}
display: "flex", {actionManager.renderAction(
flexDirection: "row", appState.multiElement ? "finalize" : "duplicateSelection",
justifyContent: "space-between", )}
}} {actionManager.renderAction("deleteSelectedElements")}
> <div>
{topLeftUI} {actionManager.renderAction("undo")}
{topRightUI} {actionManager.renderAction("redo")}
</div>
</div> </div>
); );
}; };
const renderToolbar = () => {
return (
<MobileToolBar
app={app}
onHandToolToggle={onHandToolToggle}
setAppState={setAppState}
/>
);
};
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
{/* welcome screen, bottom bar, and top bar all have the same z-index */} {!appState.viewModeEnabled &&
{/* ordered in this reverse order so that top bar is on top */} appState.openDialog?.name !== "elementLinkSelector" &&
<div className="App-welcome-screen"> renderToolbar()}
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
</div>
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN, marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}} }}
> >
<MobileShapeActions <Island padding={0}>
appState={appState} {appState.openMenu === "shape" &&
elementsMap={app.scene.getNonDeletedElementsMap()} !appState.viewModeEnabled &&
renderAction={actionManager.renderAction} appState.openDialog?.name !== "elementLinkSelector" &&
app={app} showSelectedShapeActions(appState, elements) ? (
setAppState={setAppState} <Section className="App-mobile-menu" heading="selectedShapeActions">
/> <SelectedShapeActions
appState={appState}
<Island className="App-toolbar"> elementsMap={app.scene.getNonDeletedElementsMap()}
{!appState.viewModeEnabled && renderAction={actionManager.renderAction}
appState.openDialog?.name !== "elementLinkSelector" && app={app}
renderToolbar()} />
{appState.scrolledOutside && </Section>
!appState.openMenu && ) : null}
!appState.openSidebar && ( <footer className="App-toolbar">
<button {renderAppToolbar()}
type="button" {appState.scrolledOutside &&
className="scroll-back-to-content" !appState.openMenu &&
onClick={() => { !appState.openSidebar && (
setAppState((appState) => ({ <button
...calculateScrollCenter(elements, appState), type="button"
})); className="scroll-back-to-content"
}} onClick={() => {
> setAppState((appState) => ({
{t("buttons.scrollBackToContent")} ...calculateScrollCenter(elements, appState),
</button> }));
)} }}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island> </Island>
</div> </div>
<FixedSideContainer side="top" className="App-top-bar">
{renderAppTopBar()}
</FixedSideContainer>
</> </>
); );
}; };
@@ -1,78 +0,0 @@
@import "open-color/open-color.scss";
@import "../css/variables.module.scss";
.excalidraw {
.mobile-toolbar {
display: flex;
flex: 1;
align-items: center;
padding: 0px;
gap: 4px;
border-radius: var(--space-factor);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
justify-content: space-between;
}
.mobile-toolbar::-webkit-scrollbar {
display: none;
}
.mobile-toolbar .ToolIcon {
min-width: 2rem;
min-height: 2rem;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.ToolIcon__icon {
width: 2.25rem;
height: 2.25rem;
&:hover {
background-color: transparent;
}
}
&.active {
background: var(
--color-surface-primary-container,
var(--island-bg-color)
);
border-color: var(--button-active-border, var(--color-primary-darkest));
}
svg {
width: 1rem;
height: 1rem;
}
}
.mobile-toolbar .App-toolbar__extra-tools-dropdown {
min-width: 160px;
z-index: var(--zIndex-layerUI);
}
.mobile-toolbar-separator {
width: 1px;
height: 24px;
background: var(--default-border-color);
margin: 0 2px;
flex-shrink: 0;
}
.mobile-toolbar-undo {
display: flex;
align-items: center;
}
.mobile-toolbar-undo .ToolIcon {
min-width: 32px;
min-height: 32px;
width: 32px;
height: 32px;
}
}
@@ -1,474 +0,0 @@
import { useState, useEffect, useRef } from "react";
import clsx from "clsx";
import { KEYS, capitalizeString } from "@excalidraw/common";
import { trackEvent } from "../analytics";
import { t } from "../i18n";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels";
import { HandButton } from "./HandButton";
import { ToolButton } from "./ToolButton";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { ToolPopover } from "./ToolPopover";
import {
SelectionIcon,
FreedrawIcon,
EraserIcon,
RectangleIcon,
ArrowIcon,
extraToolsIcon,
DiamondIcon,
EllipseIcon,
LineIcon,
TextIcon,
ImageIcon,
frameToolIcon,
EmbedIcon,
laserPointerToolIcon,
LassoIcon,
mermaidLogoIcon,
MagicIcon,
} from "./icons";
import "./ToolIcon.scss";
import "./MobileToolBar.scss";
import type { AppClassProperties, ToolType, UIAppState } from "../types";
const SHAPE_TOOLS = [
{
type: "rectangle",
icon: RectangleIcon,
title: capitalizeString(t("toolBar.rectangle")),
},
{
type: "diamond",
icon: DiamondIcon,
title: capitalizeString(t("toolBar.diamond")),
},
{
type: "ellipse",
icon: EllipseIcon,
title: capitalizeString(t("toolBar.ellipse")),
},
] as const;
const SELECTION_TOOLS = [
{
type: "selection",
icon: SelectionIcon,
title: capitalizeString(t("toolBar.selection")),
},
{
type: "lasso",
icon: LassoIcon,
title: capitalizeString(t("toolBar.lasso")),
},
] as const;
const LINEAR_ELEMENT_TOOLS = [
{
type: "arrow",
icon: ArrowIcon,
title: capitalizeString(t("toolBar.arrow")),
},
{ type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) },
] as const;
type MobileToolBarProps = {
app: AppClassProperties;
onHandToolToggle: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
};
export const MobileToolBar = ({
app,
onHandToolToggle,
setAppState,
}: MobileToolBarProps) => {
const activeTool = app.state.activeTool;
const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
"rectangle" | "diamond" | "ellipse"
>("rectangle");
const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
"arrow" | "line"
>("arrow");
const toolbarRef = useRef<HTMLDivElement>(null);
// keep lastActiveGenericShape in sync with active tool if user switches via other UI
useEffect(() => {
if (
activeTool.type === "rectangle" ||
activeTool.type === "diamond" ||
activeTool.type === "ellipse"
) {
setLastActiveGenericShape(activeTool.type);
}
}, [activeTool.type]);
// keep lastActiveLinearElement in sync with active tool if user switches via other UI
useEffect(() => {
if (activeTool.type === "arrow" || activeTool.type === "line") {
setLastActiveLinearElement(activeTool.type);
}
}, [activeTool.type]);
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
const handleToolChange = (toolType: string, pointerType?: string) => {
if (app.state.activeTool.type !== toolType) {
trackEvent("toolbar", toolType, "ui");
}
if (toolType === "selection") {
if (app.state.activeTool.type === "selection") {
// Toggle selection tool behavior if needed
} else {
app.setActiveTool({ type: "selection" });
}
} else {
app.setActiveTool({ type: toolType as ToolType });
}
};
const toolbarWidth =
toolbarRef.current?.getBoundingClientRect()?.width ?? 0 - 8;
const WIDTH = 36;
const GAP = 4;
// hand, selection, freedraw, eraser, rectangle, arrow, others
const MIN_TOOLS = 7;
const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP;
const ADDITIONAL_WIDTH = WIDTH + GAP;
const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH;
const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
const extraTools = [
"text",
"frame",
"embeddable",
"laser",
"magicframe",
].filter((tool) => {
if (showImageToolOutside && tool === "image") {
return false;
}
if (showFrameToolOutside && tool === "frame") {
return false;
}
return true;
});
const extraToolSelected = extraTools.includes(activeTool.type);
const extraIcon = extraToolSelected
? activeTool.type === "frame"
? frameToolIcon
: activeTool.type === "embeddable"
? EmbedIcon
: activeTool.type === "laser"
? laserPointerToolIcon
: activeTool.type === "text"
? TextIcon
: activeTool.type === "magicframe"
? MagicIcon
: extraToolsIcon
: extraToolsIcon;
return (
<div className="mobile-toolbar" ref={toolbarRef}>
{/* Hand Tool */}
<HandButton
checked={isHandToolActive(app.state)}
onChange={onHandToolToggle}
title={t("toolBar.hand")}
isMobile
/>
{/* Selection Tool */}
<ToolPopover
app={app}
options={SELECTION_TOOLS}
activeTool={activeTool}
defaultOption={app.state.preferredSelectionTool.type}
namePrefix="selectionType"
title={capitalizeString(t("toolBar.selection"))}
data-testid="toolbar-selection"
onToolChange={(type: string) => {
if (type === "selection" || type === "lasso") {
app.setActiveTool({ type });
setAppState({
preferredSelectionTool: { type, initialized: true },
});
}
}}
displayedOption={
SELECTION_TOOLS.find(
(tool) => tool.type === app.state.preferredSelectionTool.type,
) || SELECTION_TOOLS[0]
}
/>
{/* Free Draw */}
<ToolButton
className={clsx({
active: activeTool.type === "freedraw",
})}
type="radio"
icon={FreedrawIcon}
checked={activeTool.type === "freedraw"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.freedraw"))}`}
aria-label={capitalizeString(t("toolBar.freedraw"))}
data-testid="toolbar-freedraw"
onChange={() => handleToolChange("freedraw")}
/>
{/* Eraser */}
<ToolButton
className={clsx({
active: activeTool.type === "eraser",
})}
type="radio"
icon={EraserIcon}
checked={activeTool.type === "eraser"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.eraser"))}`}
aria-label={capitalizeString(t("toolBar.eraser"))}
data-testid="toolbar-eraser"
onChange={() => handleToolChange("eraser")}
/>
{/* Rectangle */}
<ToolPopover
app={app}
options={SHAPE_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveGenericShape}
namePrefix="shapeType"
title={capitalizeString(
t(
lastActiveGenericShape === "rectangle"
? "toolBar.rectangle"
: lastActiveGenericShape === "diamond"
? "toolBar.diamond"
: lastActiveGenericShape === "ellipse"
? "toolBar.ellipse"
: "toolBar.rectangle",
),
)}
data-testid="toolbar-rectangle"
onToolChange={(type: string) => {
if (
type === "rectangle" ||
type === "diamond" ||
type === "ellipse"
) {
setLastActiveGenericShape(type);
app.setActiveTool({ type });
}
}}
displayedOption={
SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) ||
SHAPE_TOOLS[0]
}
/>
{/* Arrow/Line */}
<ToolPopover
app={app}
options={LINEAR_ELEMENT_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveLinearElement}
namePrefix="linearElementType"
title={capitalizeString(
t(
lastActiveLinearElement === "arrow"
? "toolBar.arrow"
: "toolBar.line",
),
)}
data-testid="toolbar-arrow"
fillable={true}
onToolChange={(type: string) => {
if (type === "arrow" || type === "line") {
setLastActiveLinearElement(type);
app.setActiveTool({ type });
}
}}
displayedOption={
LINEAR_ELEMENT_TOOLS.find(
(tool) => tool.type === lastActiveLinearElement,
) || LINEAR_ELEMENT_TOOLS[0]
}
/>
{/* Text Tool */}
{showTextToolOutside && (
<ToolButton
className={clsx({
active: activeTool.type === "text",
})}
type="radio"
icon={TextIcon}
checked={activeTool.type === "text"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.text"))}`}
aria-label={capitalizeString(t("toolBar.text"))}
data-testid="toolbar-text"
onChange={() => handleToolChange("text")}
/>
)}
{/* Image */}
{showImageToolOutside && (
<ToolButton
className={clsx({
active: activeTool.type === "image",
})}
type="radio"
icon={ImageIcon}
checked={activeTool.type === "image"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.image"))}`}
aria-label={capitalizeString(t("toolBar.image"))}
data-testid="toolbar-image"
onChange={() => handleToolChange("image")}
/>
)}
{/* Frame Tool */}
{showFrameToolOutside && (
<ToolButton
className={clsx({ active: frameToolSelected })}
type="radio"
icon={frameToolIcon}
checked={frameToolSelected}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.frame"))}`}
aria-label={capitalizeString(t("toolBar.frame"))}
data-testid="toolbar-frame"
onChange={() => handleToolChange("frame")}
/>
)}
{/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
<DropdownMenu.Trigger
className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
{
"App-toolbar__extra-tools-trigger--selected":
extraToolSelected || isOtherShapesMenuOpen,
},
)}
onToggle={() => {
setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen);
setAppState({ openMenu: null, openPopup: null });
}}
title={t("toolBar.extraTools")}
style={{
width: WIDTH,
height: WIDTH,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{extraIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
{!showTextToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "text" })}
icon={TextIcon}
shortcut={KEYS.T.toLocaleUpperCase()}
data-testid="toolbar-text"
selected={activeTool.type === "text"}
>
{t("toolBar.text")}
</DropdownMenu.Item>
)}
{!showImageToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "image" })}
icon={ImageIcon}
data-testid="toolbar-image"
selected={activeTool.type === "image"}
>
{t("toolBar.image")}
</DropdownMenu.Item>
)}
{!showFrameToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
)}
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "embeddable" })}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu>
</div>
);
};
+1 -5
View File
@@ -3,8 +3,6 @@ import { unstable_batchedUpdates } from "react-dom";
import { KEYS, queryFocusableElements } from "@excalidraw/common"; import { KEYS, queryFocusableElements } from "@excalidraw/common";
import clsx from "clsx";
import "./Popover.scss"; import "./Popover.scss";
type Props = { type Props = {
@@ -17,7 +15,6 @@ type Props = {
offsetTop?: number; offsetTop?: number;
viewportWidth?: number; viewportWidth?: number;
viewportHeight?: number; viewportHeight?: number;
className?: string;
}; };
export const Popover = ({ export const Popover = ({
@@ -30,7 +27,6 @@ export const Popover = ({
offsetTop = 0, offsetTop = 0,
viewportWidth = window.innerWidth, viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight, viewportHeight = window.innerHeight,
className,
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@@ -150,7 +146,7 @@ export const Popover = ({
}, [onCloseRequest]); }, [onCloseRequest]);
return ( return (
<div className={clsx("popover", className)} ref={popoverRef} tabIndex={-1}> <div className="popover" ref={popoverRef} tabIndex={-1}>
{children} {children}
</div> </div>
); );
@@ -17,7 +17,6 @@ interface PropertiesPopoverProps {
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>; onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"]; onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"]; onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
preventAutoFocusOnTouch?: boolean;
} }
export const PropertiesPopover = React.forwardRef< export const PropertiesPopover = React.forwardRef<
@@ -35,13 +34,10 @@ export const PropertiesPopover = React.forwardRef<
onFocusOutside, onFocusOutside,
onPointerLeave, onPointerLeave,
onPointerDownOutside, onPointerDownOutside,
preventAutoFocusOnTouch = false,
}, },
ref, ref,
) => { ) => {
const device = useDevice(); const device = useDevice();
const isMobilePortrait =
device.editor.isMobile && !device.viewport.isLandscape;
return ( return (
<Popover.Portal container={container}> <Popover.Portal container={container}>
@@ -49,25 +45,25 @@ export const PropertiesPopover = React.forwardRef<
ref={ref} ref={ref}
className={clsx("focus-visible-none", className)} className={clsx("focus-visible-none", className)}
data-prevent-outside-click data-prevent-outside-click
side={isMobilePortrait ? "bottom" : "right"} side={
align={isMobilePortrait ? "center" : "start"} device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16} alignOffset={-16}
sideOffset={20} sideOffset={20}
collisionBoundary={container ?? undefined}
style={{ style={{
zIndex: "var(--zIndex-ui-styles-popup)", zIndex: "var(--zIndex-popup)",
marginLeft: device.editor.isMobile ? "0.5rem" : undefined,
}} }}
onPointerLeave={onPointerLeave} onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside} onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside} onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => {
// prevent auto-focus on touch devices to avoid keyboard popup
if (preventAutoFocusOnTouch && device.isTouchScreen) {
e.preventDefault();
}
}}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
e.stopPropagation(); e.stopPropagation();
// prevents focusing the trigger // prevents focusing the trigger
@@ -518,7 +518,7 @@ const PublishLibrary = ({
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
<DialogActionButton <DialogActionButton
label={t("buttons.saveLibNames")} label={t("buttons.cancel")}
onClick={onDialogClose} onClick={onDialogClose}
data-testid="cancel-clear-canvas-button" data-testid="cancel-clear-canvas-button"
/> />
@@ -9,7 +9,7 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: var(--zIndex-ui-library); z-index: 5;
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -9,13 +9,7 @@ import React, {
useCallback, useCallback,
} from "react"; } from "react";
import { import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common";
CLASSES,
EVENT,
isDevEnv,
KEYS,
updateObject,
} from "@excalidraw/common";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { atom, useSetAtom } from "../../editor-jotai"; import { atom, useSetAtom } from "../../editor-jotai";
@@ -143,11 +137,7 @@ export const SidebarInner = forwardRef(
return ( return (
<Island <Island
{...rest} {...rest}
className={clsx( className={clsx("sidebar", { "sidebar--docked": docked }, className)}
CLASSES.SIDEBAR,
{ "sidebar--docked": docked },
className,
)}
ref={islandRef} ref={islandRef}
> >
<SidebarPropsContext.Provider value={headerPropsRef.current}> <SidebarPropsContext.Provider value={headerPropsRef.current}>
@@ -30,11 +30,7 @@ export const SidebarTrigger = ({
.querySelector(".layer-ui__wrapper") .querySelector(".layer-ui__wrapper")
?.classList.remove("animate"); ?.classList.remove("animate");
const isOpen = event.target.checked; const isOpen = event.target.checked;
setAppState({ setAppState({ openSidebar: isOpen ? { name, tab } : null });
openSidebar: isOpen ? { name, tab } : null,
openMenu: null,
openPopup: null,
});
onToggle?.(isOpen); onToggle?.(isOpen);
}} }}
checked={appState.openSidebar?.name === name} checked={appState.openSidebar?.name === name}

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