Merge branch 'master' into ryan-di/freedraw-width

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-10-27 16:19:33 +01:00
167 changed files with 8971 additions and 2767 deletions
-2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services: services:
excalidraw: excalidraw:
build: build:
+9 -8
View File
@@ -20,7 +20,6 @@ import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -120,6 +119,7 @@ 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";
@@ -500,11 +500,6 @@ const ExcalidrawWrapper = () => {
} }
}; };
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
@@ -595,7 +590,6 @@ const ExcalidrawWrapper = () => {
visibilityChange, visibilityChange,
false, false,
); );
clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -735,6 +729,8 @@ 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],
@@ -909,10 +905,15 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="alertalert--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}
+2 -1
View File
@@ -8,7 +8,8 @@ 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
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB // should be aligned with MAX_ALLOWED_FILE_BYTES
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;
+4 -1
View File
@@ -530,7 +530,10 @@ 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" });
+24 -32
View File
@@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers"; } from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types"; import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react"; import { useCallback } from "react";
import { import {
isLineSegment, isLineSegment,
@@ -18,6 +18,8 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug"; import type { DebugElement } from "@excalidraw/utils/visualdebug";
@@ -113,10 +115,6 @@ const _debugRenderer = (
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps { interface DebugCanvasProps {
appState: AppState; appState: AppState;
scale: number; scale: number;
ref?: React.Ref<HTMLCanvasElement>;
} }
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => { const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
const { width, height } = appState; ({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null); return (
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>( <canvas
ref, style={{
() => canvasRef.current, width,
[canvasRef], height,
); position: "absolute",
zIndex: 2,
return ( pointerEvents: "none",
<canvas }}
style={{ width={width * scale}
width, height={height * scale}
height, ref={ref}
position: "absolute", >
zIndex: 2, Debug Canvas
pointerEvents: "none", </canvas>
}} );
width={width * scale} },
height={height * scale} );
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
export default DebugCanvas; export default DebugCanvas;
+17
View File
@@ -27,6 +27,8 @@ 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";
@@ -45,6 +47,8 @@ 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) => {
@@ -69,6 +73,9 @@ 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);
@@ -88,12 +95,22 @@ 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 {
+3 -1
View File
@@ -259,7 +259,9 @@ 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) {
+6 -1
View File
@@ -258,11 +258,16 @@ 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 | Hand-drawn look & feel • Collaborative • Secure</title> <title>Excalidraw Whiteboard</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="Excalidraw — Collaborative whiteboarding made easy" content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/> />
<meta <meta
name="description" name="description"
+11 -3
View File
@@ -58,7 +58,7 @@
} }
} }
.collab-offline-warning { .alert {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 6.5rem; top: 6.5rem;
@@ -69,10 +69,18 @@
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);
}
} }
} }
+1 -1
View File
@@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
}, },
"isTouchScreen": false, "isTouchScreen": false,
"viewport": { "viewport": {
"isLandscape": false, "isLandscape": true,
"isMobile": true, "isMobile": true,
}, },
} }
+24 -19
View File
@@ -5,17 +5,18 @@ 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 (this.scoreFunction(node) < this.scoreFunction(parent)) { if (nodeScore < 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) {
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node); const score = this.scoreFunction(node);
while (true) { while (true) {
const child2N = (idx + 1) << 1; const child1N = ((idx + 1) << 1) - 1;
const child1N = child2N - 1; const child2N = child1N + 1;
let swap = null; let smallestIdx = idx;
let child1Score = 0; let smallestScore = score;
// Check left child
if (child1N < length) { if (child1N < length) {
const child1 = this.content[child1N]; const child1Score = this.scoreFunction(this.content[child1N]);
child1Score = this.scoreFunction(child1); if (child1Score < smallestScore) {
if (child1Score < score) { smallestIdx = child1N;
swap = child1N; smallestScore = child1Score;
} }
} }
// Check right child
if (child2N < length) { if (child2N < length) {
const child2 = this.content[child2N]; const child2Score = this.scoreFunction(this.content[child2N]);
const child2Score = this.scoreFunction(child2); if (child2Score < smallestScore) {
if (child2Score < (swap === null ? score : child1Score)) { smallestIdx = child2N;
swap = child2N;
} }
} }
if (swap !== null) { if (smallestIdx === idx) {
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) {
+38 -4
View File
@@ -18,13 +18,20 @@ 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/.test(navigator.platform) || /iPad|iPhone/i.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;
@@ -36,6 +43,7 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool) // (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1; export const ELEMENT_TRANSLATE_AMOUNT = 1;
@@ -117,10 +125,12 @@ 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";
@@ -251,13 +261,20 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const MIME_TYPES = { export const STRING_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",
@@ -334,10 +351,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints // breakpoints
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730; // mobile: up to 699px
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;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -515,3 +542,10 @@ 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;
+65 -15
View File
@@ -20,7 +20,8 @@ import {
ENV, ENV,
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isDarwin, isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@@ -91,7 +92,8 @@ 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,
@@ -119,6 +121,11 @@ 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,
@@ -418,19 +425,6 @@ 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 },
{ {
@@ -1278,3 +1272,59 @@ 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;
};
+16 -4
View File
@@ -164,9 +164,14 @@ export class Scene {
return this.frames; return this.frames;
} }
constructor(elements: ElementsMapOrArray | null = null) { constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) { if (elements) {
this.replaceAllElements(elements); this.replaceAllElements(elements, options);
} }
} }
@@ -263,12 +268,19 @@ export class Scene {
return didChange; return didChange;
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(
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[] = [];
validateIndicesThrottled(_nextElements); if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
+23
View File
@@ -999,6 +999,29 @@ 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,
+39 -13
View File
@@ -43,6 +43,7 @@ import {
isBoundToContainer, isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@@ -322,19 +323,42 @@ export const getElementLineSegments = (
if (shape.type === "polycurve") { if (shape.type === "polycurve") {
const curves = shape.data; const curves = shape.data;
const points = curves const pointsOnCurves = curves.map((curve) =>
.map((curve) => pointsOnBezierCurves(curve, 10)) pointsOnBezierCurves(curve, 10),
.flat(); );
let i = 0;
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push( if (
lineSegment( (isLineElement(element) && !element.polygon) ||
pointFrom(points[i][0], points[i][1]), isArrowElement(element)
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;
@@ -1133,7 +1157,9 @@ export interface BoundingBox {
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], elements:
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
+7 -1
View File
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe"; type === "rectangle" ||
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" ||
+360 -135
View File
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene";
import { StoreSnapshot } from "./store"; import { StoreSnapshot } from "./store";
import { Scene } from "./Scene";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -150,13 +150,27 @@ 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, removed: T = {} as T,
) { ) {
const cloned = { ...prev }; const cloned = { ...prev };
@@ -496,6 +510,11 @@ 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.
*/ */
@@ -503,7 +522,11 @@ export interface DeltaContainer<T> {
} }
export class AppStateDelta implements DeltaContainer<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {} private constructor(public 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,
@@ -534,53 +557,124 @@ 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: removedSelectedElementIds = {}, selectedElementIds: deletedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {}, selectedGroupIds: deletedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
} = this.delta.deleted; } = this.delta.deleted;
const { const {
selectedElementIds: addedSelectedElementIds = {}, selectedElementIds: insertedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {}, selectedGroupIds: insertedSelectedGroupIds = {},
selectedLinearElementId, lockedMultiSelections: insertedLockedMultiSelections = {},
editingLinearElementId, selectedLinearElement: insertedSelectedLinearElement,
...directlyApplicablePartial ...directlyApplicablePartial
} = this.delta.inserted; } = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects( const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds, appState.selectedElementIds,
addedSelectedElementIds, insertedSelectedElementIds,
removedSelectedElementIds, deletedSelectedElementIds,
); );
const mergedSelectedGroupIds = Delta.mergeObjects( const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds, appState.selectedGroupIds,
addedSelectedGroupIds, insertedSelectedGroupIds,
removedSelectedGroupIds, deletedSelectedGroupIds,
);
const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
); );
const selectedLinearElement = const selectedLinearElement =
selectedLinearElementId && nextElements.has(selectedLinearElementId) insertedSelectedLinearElement &&
nextElements.has(insertedSelectedLinearElement.elementId)
? new LinearElementEditor( ? new LinearElementEditor(
nextElements.get( nextElements.get(
selectedLinearElementId, insertedSelectedLinearElement.elementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
const editingLinearElement =
editingLinearElementId && nextElements.has(editingLinearElementId)
? new LinearElementEditor(
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>, ) as NonDeleted<ExcalidrawLinearElement>,
nextElements, nextElements,
insertedSelectedLinearElement.isEditing,
) )
: null; : null;
@@ -589,14 +683,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
...directlyApplicablePartial, ...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds, selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds, selectedGroupIds: mergedSelectedGroupIds,
lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement: selectedLinearElement:
typeof selectedLinearElementId !== "undefined" typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement // element was either inserted or deleted ? selectedLinearElement
: appState.selectedLinearElement, // otherwise assign what we had before : appState.selectedLinearElement,
editingLinearElement:
typeof editingLinearElementId !== "undefined"
? editingLinearElement // element was either inserted or deleted
: appState.editingLinearElement, // otherwise assign what we had before
}; };
const constainsVisibleChanges = this.filterInvisibleChanges( const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -725,52 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElementId": case "selectedLinearElement":
case "editingLinearElementId": const nextLinearElement = nextAppState[key];
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) { if (!nextLinearElement) {
// 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(linearElement.elementId); const element = nextElements.get(nextLinearElement.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[appStateKey] = null; nextAppState[key] = null;
} }
} }
break; break;
case "lockedMultiSelections": { 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,
); );
}
} }
} }
} }
@@ -778,20 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
editingLinearElementId, selectedLinearElement,
selectedLinearElementId,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -911,12 +988,6 @@ 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.`);
@@ -945,12 +1016,13 @@ 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;
}; };
/** /**
@@ -1039,18 +1111,27 @@ 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(inserted.version) && Number.isInteger(deleted.version) &&
// versions should be positive, zero included Number.isInteger(inserted.version) &&
deleted.version >= 0 && // versions should be positive, zero included
inserted.version >= 0 && deleted.version! >= 0 &&
// versions should never be the same inserted.version! >= 0 &&
deleted.version !== inserted.version // versions should never be the same
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",
@@ -1059,6 +1140,7 @@ 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(
@@ -1095,7 +1177,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, isDeleted: false } as ElementPartial; const deleted = { ...prevElement } as ElementPartial;
const inserted = { const inserted = {
isDeleted: true, isDeleted: true,
@@ -1109,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
removed[prevElement.id] = delta; if (!prevElement.isDeleted) {
removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
}
} }
} }
@@ -1125,7 +1211,6 @@ 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(
@@ -1134,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
added[nextElement.id] = delta; // ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
}
continue; continue;
} }
@@ -1163,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
// making sure there are at least some changes updated[nextElement.id] = delta;
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
} }
} }
@@ -1181,8 +1268,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, delta] of Object.entries(deltas)) { for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
} }
return inversedDeltas; return inversedDeltas;
@@ -1301,9 +1388,7 @@ 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>;
@@ -1311,22 +1396,28 @@ 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,
options,
flags, flags,
options,
); );
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(elements, nextElements); const affectedElements = this.resolveConflicts(
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([
@@ -1350,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
try { try {
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs mutations, but only on new instances of changed elements,
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // 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,
); );
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry ElementsDelta.redrawElements(nextElements, changedElements);
// 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`,
@@ -1380,12 +1464,113 @@ 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(
@@ -1398,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsDelta.applyDelta( const nextElement = ElementsDelta.applyDelta(
element, element,
delta, delta,
options,
flags, flags,
options,
); );
nextElements.set(newElement.id, newElement); nextElements.set(nextElement.id, nextElement);
acc.set(newElement.id, newElement); acc.set(nextElement.id, nextElement);
if (!flags.applyDirection) {
const prevElement = prevElements.get(id);
if (prevElement) {
flags.applyDirection =
prevElement.version > nextElement.version
? "backward"
: "forward";
}
}
} }
return acc; return acc;
@@ -1451,8 +1647,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> = {};
@@ -1466,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
if (options.excludedProperties.has(key)) { if (options?.excludedProperties?.has(key)) {
continue; continue;
} }
@@ -1506,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index; delta.deleted.index !== delta.inserted.index;
} }
return newElementWith(element, directlyApplicablePartial); return newElementWith(element, directlyApplicablePartial, true);
} }
/** /**
@@ -1546,6 +1742,7 @@ 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 = (
@@ -1557,21 +1754,36 @@ 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 (prevElements.get(element.id) === nextElement) { if (prevElement === 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( affectedElement = mutateElement(nextElement, nextElements, {
nextElement, ...elementUpdates,
nextElements, // don't modify the version further, if it's already different
updates as ElementUpdate<OrderedExcalidrawElement>, version:
); prevElement?.version !== nextElement.version
? nextElement.version
: nextVersion,
});
} }
nextAffectedElements.set(affectedElement.id, affectedElement); nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1609,25 +1821,12 @@ 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 assign them back to all the deltas // calculate complete deltas for affected elements, and squash them back to the current deltas
// technically we could do better here if perf. would become an issue this.squash(
const { added, removed, updated } = ElementsDelta.calculate( // technically we could do better here if perf. would become an issue
prevAffectedElements, ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
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;
} }
@@ -1689,6 +1888,31 @@ 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>,
@@ -1743,6 +1967,7 @@ 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,
}); });
+30 -17
View File
@@ -359,6 +359,12 @@ 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
@@ -706,7 +712,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) =>
@@ -741,8 +747,15 @@ 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[startIsSpecial ? 2 : 1]; const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; const thirdPoint = globalUpdatedPoints.at(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)),
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = const secondToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
const thirdToLastPoint = );
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)]; const thirdToLastPoint = globalUpdatedPoints.at(
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,
@@ -2071,16 +2093,7 @@ 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) =>
+4
View File
@@ -29,6 +29,9 @@ 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++) {
@@ -98,6 +101,7 @@ 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";
+35 -28
View File
@@ -149,10 +149,12 @@ export class LinearElementEditor {
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null; public readonly customLineAngle: number | null;
public readonly isEditing: boolean;
constructor( constructor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
isEditing: boolean = false,
) { ) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@@ -187,6 +189,7 @@ export class LinearElementEditor {
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null; this.customLineAngle = null;
this.isEditing = isEditing;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -194,6 +197,7 @@ export class LinearElementEditor {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 10; static POINT_HANDLE_SIZE = 10;
/** /**
* @param id the `elementId` from the instance of this class (so that we can * @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement) * statically guarantee this method returns an ExcalidrawLinearElement)
@@ -215,11 +219,14 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"], setState: React.Component<any, AppState>["setState"],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
) { ) {
if (!appState.editingLinearElement || !appState.selectionElement) { if (
!appState.selectedLinearElement?.isEditing ||
!appState.selectionElement
) {
return false; return false;
} }
const { editingLinearElement } = appState; const { selectedLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement; const { selectedPointsIndices, elementId } = selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
@@ -260,8 +267,8 @@ export class LinearElementEditor {
}); });
setState({ setState({
editingLinearElement: { selectedLinearElement: {
...editingLinearElement, ...selectedLinearElement,
selectedPointsIndices: nextSelectedPoints.length selectedPointsIndices: nextSelectedPoints.length
? nextSelectedPoints ? nextSelectedPoints
: null, : null,
@@ -479,9 +486,6 @@ export class LinearElementEditor {
return { return {
...app.state, ...app.state,
editingLinearElement: app.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor, selectedLinearElement: newLinearElementEditor,
suggestedBindings, suggestedBindings,
}; };
@@ -618,7 +622,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text // Since its not needed outside editor unless 2 pointer lines or bound text
if ( if (
!isElbowArrow(element) && !isElbowArrow(element) &&
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
element.points.length > 2 && element.points.length > 2 &&
!boundText !boundText
) { ) {
@@ -684,7 +688,7 @@ export class LinearElementEditor {
); );
if ( if (
points.length >= 3 && points.length >= 3 &&
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
!isElbowArrow(element) !isElbowArrow(element)
) { ) {
return null; return null;
@@ -881,7 +885,7 @@ export class LinearElementEditor {
segmentMidpoint, segmentMidpoint,
elementsMap, elementsMap,
); );
} else if (event.altKey && appState.editingLinearElement) { } else if (event.altKey && appState.selectedLinearElement?.isEditing) {
if (linearElementEditor.lastUncommittedPoint == null) { if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutateElement(element, { scene.mutateElement(element, {
points: [ points: [
@@ -1023,14 +1027,14 @@ export class LinearElementEditor {
app: AppClassProperties, app: AppClassProperties,
): LinearElementEditor | null { ): LinearElementEditor | null {
const appState = app.state; const appState = app.state;
if (!appState.editingLinearElement) { if (!appState.selectedLinearElement?.isEditing) {
return null; return null;
} }
const { elementId, lastUncommittedPoint } = appState.editingLinearElement; const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return appState.editingLinearElement; return appState.selectedLinearElement;
} }
const { points } = element; const { points } = element;
@@ -1040,10 +1044,12 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]); LinearElementEditor.deletePoints(element, app, [points.length - 1]);
} }
return { return appState.selectedLinearElement?.lastUncommittedPoint
...appState.editingLinearElement, ? {
lastUncommittedPoint: null, ...appState.selectedLinearElement,
}; lastUncommittedPoint: null,
}
: appState.selectedLinearElement;
} }
let newPoint: LocalPoint; let newPoint: LocalPoint;
@@ -1067,8 +1073,8 @@ export class LinearElementEditor {
newPoint = LinearElementEditor.createPointAt( newPoint = LinearElementEditor.createPointAt(
element, element,
elementsMap, elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerX - appState.selectedLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y, scenePointerY - appState.selectedLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null ? null
: app.getEffectiveGridSize(), : app.getEffectiveGridSize(),
@@ -1092,7 +1098,7 @@ export class LinearElementEditor {
LinearElementEditor.addPoints(element, app.scene, [newPoint]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
...appState.editingLinearElement, ...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1], lastUncommittedPoint: element.points[element.points.length - 1],
}; };
} }
@@ -1251,12 +1257,12 @@ export class LinearElementEditor {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant( invariant(
appState.editingLinearElement, appState.selectedLinearElement?.isEditing,
"Not currently editing a linear element", "Not currently editing a linear element",
); );
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement; const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
invariant( invariant(
@@ -1318,8 +1324,8 @@ export class LinearElementEditor {
return { return {
...appState, ...appState,
editingLinearElement: { selectedLinearElement: {
...appState.editingLinearElement, ...appState.selectedLinearElement,
selectedPointsIndices: nextSelectedIndices, selectedPointsIndices: nextSelectedIndices,
}, },
}; };
@@ -1331,8 +1337,9 @@ export class LinearElementEditor {
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
const isUncommittedPoint = const isUncommittedPoint =
app.state.editingLinearElement?.lastUncommittedPoint === app.state.selectedLinearElement?.isEditing &&
element.points[element.points.length - 1]; app.state.selectedLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const nextPoints = element.points.filter((_, idx) => { const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx); return !pointIndices.includes(idx);
@@ -1505,7 +1512,7 @@ export class LinearElementEditor {
pointFrom(pointerCoords.x, pointerCoords.y), pointFrom(pointerCoords.x, pointerCoords.y),
); );
if ( if (
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
dist < DRAGGING_THRESHOLD / appState.zoom.value dist < DRAGGING_THRESHOLD / appState.zoom.value
) { ) {
return false; return false;
@@ -0,0 +1,112 @@
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;
};
+5
View File
@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12; return element.strokeWidth * 12;
case "text": case "text":
return element.fontSize / 2; return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default: default:
return 20; return 20;
} }
+19 -3
View File
@@ -35,6 +35,7 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@@ -225,7 +226,16 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
scene.mutateElement(textElement, { angle }); const { x, y } = computeBoundTextPosition(
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@@ -416,9 +426,15 @@ 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: boundText.x + (rotatedCX - cx), x,
y: boundText.y + (rotatedCY - cy), y,
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }
+38 -15
View File
@@ -27,6 +27,8 @@ import {
isImageElement, isImageElement,
} from "./index"; } from "./index";
import type { ApplyToOptions } from "./delta";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
@@ -74,8 +76,9 @@ 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 {
// internally used by history // for internal use 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]
>(); >();
@@ -237,7 +240,6 @@ 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);
} }
@@ -550,10 +552,26 @@ 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, AppStateDelta.empty()); return new this(id, elements, appState);
}
/**
* 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;
} }
/** /**
@@ -570,9 +588,13 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
delta.elements.applyTo(elements); elements,
StoreSnapshot.empty().elements,
options,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@@ -605,6 +627,10 @@ 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();
} }
@@ -970,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
editingLinearElementId: null, selectedLinearElement: null,
selectedLinearElementId: null,
croppingElementId: null, croppingElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
@@ -990,14 +1015,12 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
editingLinearElementId: selectedLinearElement: appState.selectedLinearElement
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer ? {
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot elementId: appState.selectedLinearElement.elementId,
null, isEditing: !!appState.selectedLinearElement.isEditing,
selectedLinearElementId: }
(appState as AppState).selectedLinearElement?.elementId ?? : null,
(appState as ObservedAppState).selectedLinearElementId ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+22 -2
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,6 +254,26 @@ 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 };
}; };
+6 -3
View File
@@ -2,6 +2,7 @@ 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";
@@ -326,11 +327,11 @@ export const getTransformHandles = (
); );
}; };
export const shouldShowBoundingBox = ( export const hasBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
) => { ) => {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
return false; return false;
} }
if (elements.length > 1) { if (elements.length > 1) {
@@ -345,5 +346,7 @@ export const shouldShowBoundingBox = (
return true; return true;
} }
return element.points.length > 2; // on mobile/tablet we currently don't show bbox because of resize issues
// (also prob best for simplicity's sake)
return element.points.length > 2 && !isMobileOrTablet();
}; };
+8 -10
View File
@@ -10,6 +10,8 @@ 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,
@@ -155,10 +157,10 @@ describe("element binding", () => {
// NOTE this mouse down/up + await needs to be done in order to repro // NOTE this mouse down/up + await needs to be done in order to repro
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset(); mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
mouse.down(0, 0); mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(API.getSelectedElement().type).toBe("rectangle"); expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up(); mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle"); expect(API.getSelectedElement().type).toBe("rectangle");
@@ -413,16 +415,12 @@ 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 handles = getTransformHandles( const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
h.state.zoom, -1,
arrayToMap(h.elements), h.scene.getNonDeletedElementsMap(),
"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();
+434 -10
View File
@@ -1,13 +1,345 @@
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 } from "../src/delta"; import { AppStateDelta, Delta, ElementsDelta } 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 selectedLinearElementId = "id1" as LinearElementEditor["elementId"]; const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = { const commonAppState = {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
@@ -16,6 +348,7 @@ describe("AppStateDelta", () => {
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
editingLinearElementId: null, editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
}; };
@@ -23,23 +356,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = { const prevAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name: "", name: "",
selectedLinearElementId: null, selectedLinearElement: null,
}; };
const nextAppState1: ObservedAppState = { const nextAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name, name,
selectedLinearElementId, selectedLinearElement,
}; };
const prevAppState2: ObservedAppState = { const prevAppState2: ObservedAppState = {
selectedLinearElementId: null, selectedLinearElement: null,
name: "", name: "",
...commonAppState, ...commonAppState,
}; };
const nextAppState2: ObservedAppState = { const nextAppState2: ObservedAppState = {
selectedLinearElementId, selectedLinearElement,
name, name,
...commonAppState, ...commonAppState,
}; };
@@ -57,8 +390,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -104,8 +436,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {}, selectedElementIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -146,4 +477,97 @@ 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 },
},
),
);
});
});
}); });
+128
View File
@@ -0,0 +1,128 @@
import {
distributeHorizontally,
distributeVertically,
} from "@excalidraw/excalidraw/actions";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
unmountComponent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
const mouse = new Pointer("mouse");
// Scenario: three rectangles that will be distributed with gaps
const createAndSelectThreeRectanglesWithGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
// Scenario: three rectangles that will be distributed by their centers
const createAndSelectThreeRectanglesWithoutGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(200, 200);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
describe("distributing", () => {
beforeEach(async () => {
unmountComponent();
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(300);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(300);
});
it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(300);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(300);
});
it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
});
@@ -136,7 +136,8 @@ describe("Test Linear Elements", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(line.id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
}; };
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
}); });
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor via enter (line)", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
// ctrl+enter alias (to align with arrows) // ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => { it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor via ctrl+enter (arrow)", () => { it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on ctrl+dblclick (simple arrow)", () => { it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick(); mouse.doubleClick();
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on ctrl+dblclick (line)", () => { it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick(); mouse.doubleClick();
}); });
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on dblclick (line)", () => { it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should not enter line editor on dblclick (arrow)", async () => { it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(null); expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor(); await getTextEditor();
}); });
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
const arrow = h.elements[0] as ExcalidrawLinearElement; const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow); enterLineEditingMode(arrow);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
@@ -367,7 +377,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -469,7 +479,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -537,7 +547,7 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -588,7 +598,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -629,7 +639,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -677,7 +687,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -735,7 +745,7 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -833,7 +843,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
+171 -1
View File
@@ -1,13 +1,14 @@
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 } from "@excalidraw/common"; import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } 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";
@@ -207,3 +208,172 @@ 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);
});
});
});
+3 -1
View File
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element";
@@ -30,6 +30,8 @@ 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,6 +8,7 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
isBoundToContainer,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -225,7 +226,9 @@ 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((el) => isTextElement(el)); const someTextElements = selectedElements.some(
(el) => isTextElement(el) && !isBoundToContainer(el),
);
return selectedElements.length > 0 && someTextElements; return selectedElements.length > 0 && someTextElements;
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@@ -234,7 +237,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)) { if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
const container = newElement({ const container = newElement({
type: "rectangle", type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor, backgroundColor: appState.currentItemBackgroundColor,
+12 -5
View File
@@ -7,7 +7,6 @@ import {
MIN_ZOOM, MIN_ZOOM,
THEME, THEME,
ZOOM_STEP, ZOOM_STEP,
getShortcutKey,
updateActiveTool, updateActiveTool,
CODES, CODES,
KEYS, KEYS,
@@ -46,6 +45,7 @@ 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 }) => { PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx // FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<ColorPicker <ColorPicker
@@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
); );
}, },
@@ -121,7 +122,10 @@ 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,
@@ -494,13 +498,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool", name: "toggleEraserTool",
label: "toolBar.eraser", label: "toolBar.eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
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: "selection", type: app.state.preferredSelectionTool.type,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -530,6 +534,9 @@ 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,4 +1,8 @@
import { KEYS, updateActiveTool } from "@excalidraw/common"; import {
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";
@@ -205,16 +209,19 @@ export const actionDeleteSelected = register({
icon: TrashIcon, icon: TrashIcon,
trackEvent: { category: "element", action: "delete" }, trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
const { const {
elementId, elementId,
selectedPointsIndices, selectedPointsIndices,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
} = appState.editingLinearElement; } = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const linearElement = LinearElementEditor.getElement(
if (!element) { elementId,
elementsMap,
);
if (!linearElement) {
return false; return false;
} }
// case: no point selected → do nothing, as deleting the whole element // case: no point selected → do nothing, as deleting the whole element
@@ -225,10 +232,10 @@ export const actionDeleteSelected = register({
return false; return false;
} }
// case: deleting last remaining point // case: deleting all points
if (element.points.length < 2) { if (selectedPointsIndices.length >= linearElement.points.length) {
const nextElements = elements.map((el) => { const nextElements = elements.map((el) => {
if (el.id === element.id) { if (el.id === linearElement.id) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
return el; return el;
@@ -239,7 +246,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
editingLinearElement: null, selectedLinearElement: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -252,20 +259,24 @@ export const actionDeleteSelected = register({
? null ? null
: startBindingElement, : startBindingElement,
endBindingElement: selectedPointsIndices?.includes( endBindingElement: selectedPointsIndices?.includes(
element.points.length - 1, linearElement.points.length - 1,
) )
? null ? null
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints(element, app, selectedPointsIndices); LinearElementEditor.deletePoints(
linearElement,
app,
selectedPointsIndices,
);
return { return {
elements, elements,
appState: { appState: {
...appState, ...appState,
editingLinearElement: { selectedLinearElement: {
...appState.editingLinearElement, ...appState.selectedLinearElement,
...binding, ...binding,
selectedPointsIndices: selectedPointsIndices:
selectedPointsIndices?.[0] > 0 selectedPointsIndices?.[0] > 0
@@ -291,7 +302,9 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }), activeTool: updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
}),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
@@ -314,7 +327,15 @@ 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)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} disabled={
!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, getShortcutKey } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
@@ -26,6 +26,8 @@ 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,6 +25,7 @@ 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";
@@ -39,7 +40,7 @@ export const actionDuplicateSelection = register({
} }
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints() // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try { try {
const newAppState = LinearElementEditor.duplicateSelectedPoints( const newAppState = LinearElementEditor.duplicateSelectedPoints(
@@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({
)}`} )}`}
aria-label={t("labels.duplicateSelection")} aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(appState.stylesPanelMode === "mobile" &&
appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
), ),
}); });
+36 -15
View File
@@ -5,7 +5,11 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
@@ -78,7 +82,14 @@ 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.filter((el) => el.id !== element!.id); newElements = newElements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
} }
return { return {
elements: newElements, elements: newElements,
@@ -94,9 +105,9 @@ export const actionFinalize = register({
} }
} }
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) { if (element) {
@@ -117,12 +128,21 @@ export const actionFinalize = register({
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id) ? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, selectedLinearElement: new LinearElementEditor(
element,
arrayToMap(elementsMap),
false, // exit editing mode
),
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -154,11 +174,7 @@ export const actionFinalize = register({
if (element) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (appState.multiElement && element.type !== "freedraw") {
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = element; const { points, lastCommittedPoint } = element;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
@@ -172,7 +188,12 @@ 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.filter((el) => el.id !== element!.id); newElements = newElements.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
} }
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
@@ -240,13 +261,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: "selection", type: app.state.preferredSelectionTool.type,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: "selection", type: app.state.preferredSelectionTool.type,
}); });
} }
@@ -289,7 +310,7 @@ export const actionFinalize = register({
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || (appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) || (!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
+3 -1
View File
@@ -14,7 +14,7 @@ import {
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@@ -43,6 +43,8 @@ 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";
+19 -3
View File
@@ -1,4 +1,10 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; import {
isWindows,
KEYS,
matchKey,
arrayToMap,
MOBILE_ACTION_BUTTON_BG,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -67,7 +73,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: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -85,6 +91,11 @@ 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
: {}),
}}
/> />
); );
}, },
@@ -103,7 +114,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: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -121,6 +132,11 @@ 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
: {}),
}}
/> />
); );
}, },
@@ -1,10 +1,9 @@
import { LinearElementEditor } from "@excalidraw/element";
import { import {
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap, invariant } from "@excalidraw/common";
import { import {
toggleLinePolygonState, toggleLinePolygonState,
@@ -46,7 +45,7 @@ export const actionToggleLinearEditor = register({
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
!appState.editingLinearElement && !appState.selectedLinearElement?.isEditing &&
selectedElements.length === 1 && selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) && isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0]) !isElbowArrow(selectedElements[0])
@@ -61,14 +60,25 @@ export const actionToggleLinearEditor = register({
includeBoundTextElement: true, includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
const editingLinearElement = invariant(selectedElement, "No selected element found");
appState.editingLinearElement?.elementId === selectedElement.id invariant(
? null appState.selectedLinearElement,
: new LinearElementEditor(selectedElement, arrayToMap(elements)); "No selected linear element found",
);
invariant(
selectedElement.id === appState.selectedLinearElement.elementId,
"Selected element ID and linear editor elementId does not match",
);
const selectedLinearElement = {
...appState.selectedLinearElement,
isEditing: !appState.selectedLinearElement.isEditing,
};
return { return {
appState: { appState: {
...appState, ...appState,
editingLinearElement, selectedLinearElement,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -78,6 +88,10 @@ 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, getShortcutKey } from "@excalidraw/common"; import { KEYS } 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";
+3 -55
View File
@@ -1,65 +1,11 @@
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 { ToolButton } from "../components/ToolButton"; import { HelpIconThin } from "../components/icons";
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",
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
: { : {
name: "help", name: "help",
}, },
openMenu: null,
openPopup: null,
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
+110 -35
View File
@@ -17,7 +17,6 @@ import {
randomInteger, randomInteger,
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
@@ -141,6 +140,13 @@ 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";
@@ -325,9 +331,11 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<> <>
<h3 aria-hidden="true">{t("labels.stroke")}</h3> {appState.stylesPanelMode === "full" && (
<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}
@@ -345,6 +353,10 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@@ -402,9 +414,11 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<> <>
<h3 aria-hidden="true">{t("labels.background")}</h3> {appState.stylesPanelMode === "full" && (
<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}
@@ -422,6 +436,10 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@@ -549,7 +567,7 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -587,7 +605,7 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -640,7 +658,7 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeStyle")}</legend> <legend>{t("labels.strokeStyle")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -773,7 +791,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 }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -832,7 +850,15 @@ export const actionChangeFontSize = register({
? null ? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE, : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1169,21 +1195,30 @@ export const actionChangeFontFamily = register({
}, []); }, []);
return ( return (
<fieldset> <>
<legend>{t("labels.fontFamily")}</legend> {appState.stylesPanelMode === "full" && (
<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) => {
setBatchedData({ withCaretPositionPreservation(
openPopup: null, () => {
currentHoveredFontFamily: null, setBatchedData({
currentItemFontFamily: fontFamily, openPopup: null,
}); currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
// defensive clear so immediate close won't abuse the cached elements });
cachedElementsRef.current.clear(); // defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
},
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@@ -1240,29 +1275,33 @@ export const actionChangeFontFamily = register({
} }
setBatchedData({ setBatchedData({
...batchedData,
openPopup: "fontFamily", openPopup: "fontFamily",
}); });
} else { } else {
// close, use the cache and clear it afterwards const fontFamilyData = {
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;
if (isUnmounted.current) { setBatchedData({
// in case the component was unmounted by the parent, trigger the update directly ...fontFamilyData,
updateData({ ...batchedData, ...data }); });
} else {
setBatchedData(data);
}
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
// 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
}
} }
}} }}
/> />
</fieldset> </>
); );
}, },
}); });
@@ -1301,8 +1340,9 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@@ -1351,7 +1391,15 @@ export const actionChangeTextAlign = register({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1393,7 +1441,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@@ -1443,7 +1491,15 @@ export const actionChangeVerticalAlign = register({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1692,6 +1748,25 @@ 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",
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
return false; return false;
} }
+2 -1
View File
@@ -1,4 +1,4 @@
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common"; import { KEYS, CODES, isDarwin } from "@excalidraw/common";
import { import {
moveOneLeft, moveOneLeft,
@@ -16,6 +16,7 @@ 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";
+2 -5
View File
@@ -19,6 +19,7 @@ export {
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -44,11 +45,7 @@ export {
} from "./actionExport"; } from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { export { actionShortcuts } from "./actionMenu";
actionToggleCanvasMenu,
actionToggleEditMenu,
actionShortcuts,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";
+2 -1
View File
@@ -1,8 +1,9 @@
import { isDarwin, getShortcutKey } from "@excalidraw/common"; import { isDarwin } 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";
+1 -2
View File
@@ -70,10 +70,9 @@ export type ActionName =
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeStrokeType" | "changeStrokeType"
| "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu"
| "toggleEditMenu"
| "undo" | "undo"
| "redo" | "redo"
| "finalize" | "finalize"
+7 -2
View File
@@ -49,7 +49,6 @@ export const getDefaultAppState = (): Omit<
newElement: null, newElement: null,
editingTextElement: null, editingTextElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null,
activeTool: { activeTool: {
type: "selection", type: "selection",
customType: null, customType: null,
@@ -57,6 +56,10 @@ 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,
@@ -125,6 +128,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
stylesPanelMode: "full",
}; };
}; };
@@ -181,8 +185,8 @@ const APP_STATE_STORAGE_CONF = (<
newElement: { browser: false, export: false, server: false }, newElement: { browser: false, export: false, server: false },
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 },
editingLinearElement: { browser: false, 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 },
@@ -255,6 +259,7 @@ 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 = <
+87 -62
View File
@@ -1,6 +1,7 @@
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";
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
text = "123"; text = "123";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }), await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }), await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 }); text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }), await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/plain": json, types: {
}, "text/plain": json,
}), },
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
}); });
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": json, types: {
}, "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(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<div> ${json}</div>`, types: {
}, "text/html": `<div> ${json}</div>`,
}), },
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<img src="https://example.com/image.png" />`, types: {
}, "text/html": `<img src="https://example.com/image.png" />`,
}), },
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
]); ]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`, types: {
}, "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([
{ {
@@ -114,11 +131,13 @@ 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(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`, types: {
}, "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([
{ {
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/plain": `a b types: {
1 2 "text/plain": `a b
4 5 1 2
7 10`, 4 5
}, 7 10`,
}), },
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `a b types: {
1 2 "text/html": `a b
4 5 1 2
7 10`, 4 5
}, 7 10`,
}), },
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<html> types: {
<body> "text/html": `<html>
<!--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> <!--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-->
</html>`, </body>
"text/plain": `a b </html>`,
1 2 "text/plain": `a b
4 5 1 2
7 10`, 4 5
}, 7 10`,
}), },
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
+174 -18
View File
@@ -5,6 +5,7 @@ import {
arrayToMap, arrayToMap,
isMemberOf, isMemberOf,
isPromiseLike, isPromiseLike,
EVENT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element";
@@ -16,15 +17,26 @@ 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 { createFile, isSupportedImageFileType } from "./data/blob"; import {
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";
@@ -92,7 +104,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided"); console.warn("createPasteEvent: no types or files provided");
} }
const event = new ClipboardEvent("paste", { const event = new ClipboardEvent(EVENT.PASTE, {
clipboardData: new DataTransfer(), clipboardData: new DataTransfer(),
}); });
@@ -101,10 +113,11 @@ 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?.setData(type, value); event.clipboardData?.items.add(value, type);
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`);
} }
@@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLPaste = ( const maybeParseHTMLDataItem = (
event: ClipboardEvent, dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData(MIME_TYPES.html); const html = dataItem.value;
if (!html) {
return null;
}
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEventTextData = async (
event: ClipboardEvent, dataList: ParsedDataTranferList,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEventTextData> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const htmlItem = dataList.findByType(MIME_TYPES.html);
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:
event.clipboardData?.getData(MIME_TYPES.text) || dataList.getData(MIME_TYPES.text) ??
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@@ -354,23 +366,156 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
const text = event.clipboardData?.getData(MIME_TYPES.text); return {
type: "text",
return { type: "text", value: (text || "").trim() }; value: (dataList.getData(MIME_TYPES.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 (
event: ClipboardEvent, dataList: ParsedDataTranferList,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEventTextData(
event, dataList,
isPlainPaste, isPlainPaste,
); );
@@ -519,3 +664,14 @@ 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,3 +91,118 @@
} }
} }
} }
.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,8 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } 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,6 +7,12 @@
} }
} }
.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;
@@ -22,6 +28,12 @@
@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 {
@@ -80,6 +92,16 @@
} }
} }
.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;
@@ -141,6 +163,15 @@
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,11 +1,12 @@
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 } from "react"; import { useRef, useEffect } 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";
@@ -18,7 +19,12 @@ 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 } from "../icons"; import { slashIcon, strokeIcon } 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";
@@ -67,6 +73,7 @@ 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 = ({
@@ -77,6 +84,8 @@ const ColorPickerPopupContent = ({
elements, elements,
palette = COLOR_PALETTE, palette = COLOR_PALETTE,
updateData, updateData,
getOpenPopup,
appState,
}: Pick< }: Pick<
ColorPickerProps, ColorPickerProps,
| "type" | "type"
@@ -86,7 +95,10 @@ 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);
@@ -117,9 +129,13 @@ 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
focusPickerContent(); if (!isWritableElement(event.target)) {
focusPickerContent();
}
event.preventDefault(); event.preventDefault();
}} }}
onPointerDownOutside={(event) => { onPointerDownOutside={(event) => {
@@ -131,8 +147,23 @@ const ColorPickerPopupContent = ({
} }
}} }}
onClose={() => { onClose={() => {
updateData({ openPopup: null }); // only clear if we're still the active popup (avoid racing with switch)
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 ? (
@@ -141,7 +172,17 @@ 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) => {
@@ -168,12 +209,18 @@ 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>
@@ -188,11 +235,32 @@ 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"
@@ -200,6 +268,9 @@ 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}
@@ -208,8 +279,26 @@ 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>
); );
}; };
@@ -225,24 +314,62 @@ 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 role="dialog" aria-modal="true" className="color-picker-container"> <div
<TopPicks role="dialog"
activeColor={color} aria-modal="true"
onChange={onChange} className={clsx("color-picker-container", {
type={type} "color-picker-container--no-top-picks": compactMode,
topPicks={topPicks} })}
/> >
<ButtonSeparator /> {!compactMode && (
<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) => {
updateData({ openPopup: open ? type : null }); if (open) {
updateData({ openPopup: type });
}
}} }}
> >
{/* serves as an active color indicator as well */} {/* serves as an active color indicator as well */}
<ColorPickerTrigger color={color} label={label} type={type} /> <ColorPickerTrigger
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
@@ -253,6 +380,8 @@ 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,8 +37,10 @@ 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(
@@ -51,11 +53,21 @@ 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 [];
@@ -154,6 +166,8 @@ 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>
@@ -175,12 +189,18 @@ 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 color={color} onChange={onChange} palette={palette} /> <ShadeList
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div> </div>
{children} {children}
</div> </div>
@@ -20,6 +20,7 @@ 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 = ({
@@ -27,6 +28,7 @@ const PickerColorList = ({
color, color,
onChange, onChange,
activeShade, activeShade,
showHotKey = true,
}: PickerColorListProps) => { }: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color, color,
@@ -82,7 +84,7 @@ const PickerColorList = ({
key={key} key={key}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} /> {showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
</button> </button>
); );
})} })}
@@ -16,9 +16,15 @@ 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 = ({ color, onChange, palette }: ShadeListProps) => { export const ShadeList = ({
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent", color: color || "transparent",
palette, palette,
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
}} }}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade /> {showHotKey && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button> </button>
))} ))}
</div> </div>
@@ -100,6 +100,19 @@ $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);
} }
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
} }
.icon { .icon {
width: 16px; width: var(--icon-size, 1rem);
height: 16px; height: var(--icon-size, 1rem);
margin-right: 6px; margin-right: 0.375rem;
.library-item-icon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
} }
} }
} }
@@ -1,18 +1,19 @@
import clsx from "clsx"; import clsx from "clsx";
import fuzzy from "fuzzy"; import fuzzy from "fuzzy";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useMemo, 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 {
@@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify"; import { Ellipsify } from "../Ellipsify";
import * as defaultItems from "./defaultCommandPaletteItems"; import {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
} from "../../data/library";
import {
useLibraryCache,
useLibraryItemSvg,
} from "../../hooks/useLibraryItemSvg";
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, UIAppState } from "../../types"; import type { AppProps, AppState, LibraryItem, 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";
@@ -80,6 +90,7 @@ 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) => {
@@ -207,6 +218,34 @@ 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.
@@ -438,7 +477,6 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementStroke", openPopup: "elementStroke",
})); }));
}, },
@@ -458,7 +496,6 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementBackground", openPopup: "elementBackground",
})); }));
}, },
@@ -588,8 +625,9 @@ function CommandPaletteInner({
setAllCommands(allCommands); setAllCommands(allCommands);
setLastUsed( setLastUsed(
allCommands.find((command) => command.label === lastUsed?.label) ?? [...allCommands, ...libraryCommands].find(
null, (command) => command.label === lastUsed?.label,
) ?? null,
); );
} }
}, [ }, [
@@ -600,6 +638,7 @@ function CommandPaletteInner({
lastUsed?.label, lastUsed?.label,
setLastUsed, setLastUsed,
setAppState, setAppState,
libraryCommands,
]); ]);
const [commandSearch, setCommandSearch] = useState(""); const [commandSearch, setCommandSearch] = useState("");
@@ -796,9 +835,17 @@ function CommandPaletteInner({
return nextCommandsByCategory; return nextCommandsByCategory;
}; };
let matchingCommands = allCommands let matchingCommands =
.filter(isCommandAvailable) commandSearch?.length > 1
.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);
@@ -822,14 +869,20 @@ 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
@@ -904,6 +957,7 @@ 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>
@@ -919,6 +973,20 @@ 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,
@@ -928,6 +996,7 @@ const CommandItem = ({
onClick, onClick,
showShortcut, showShortcut,
appState, appState,
size = "small",
}: { }: {
command: CommandPaletteItem; command: CommandPaletteItem;
isSelected: boolean; isSelected: boolean;
@@ -936,6 +1005,7 @@ 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 = () => {};
@@ -944,6 +1014,7 @@ 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) {
@@ -959,6 +1030,8 @@ 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,6 +1,10 @@
@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,6 +64,7 @@ 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,5 +1,8 @@
.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;
@@ -30,6 +33,17 @@
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"; type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
interface LogoProps { interface LogoProps {
size?: LogoSize; size?: LogoSize;
@@ -11,5 +11,10 @@
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,4 +1,5 @@
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";
@@ -58,6 +59,7 @@ 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(
@@ -69,6 +71,7 @@ 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(
@@ -81,18 +84,30 @@ export const FontPicker = React.memo(
); );
return ( return (
<div role="dialog" aria-modal="true" className="FontPicker__container"> <div
<div className="buttonList"> role="dialog"
<RadioSelection<FontFamilyValues | false> aria-modal="true"
type="button" className={clsx("FontPicker__container", {
options={defaultFonts} "FontPicker__container--compact": compactMode,
value={selectedFontFamily} })}
onClick={onSelectCallback} >
/> {!compactMode && (
</div> <div className="buttonList">
<ButtonSeparator /> <RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}> <Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} /> <FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
compactMode={compactMode}
/>
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
@@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
onClose, onClose,
}: FontPickerListProps) => { }: FontPickerListProps) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const { fonts } = useApp(); const app = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -187,6 +188,42 @@ 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({
@@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect, onSelect: wrappedOnSelect,
onHover, onHover,
onClose, onClose,
}); });
@@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, onSelect, onHover, onClose], [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@@ -240,7 +277,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) => {
onSelect(Number(e.currentTarget.value)); wrappedOnSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@@ -282,15 +319,32 @@ 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}
> >
<QuickSearch {app.state.stylesPanelMode === "full" && (
ref={inputRef} <QuickSearch
placeholder={t("quickSearch.placeholder")} ref={inputRef}
onChange={debounce(setSearchTerm, 20)} placeholder={t("quickSearch.placeholder")}
/> 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,5 +1,6 @@
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";
@@ -7,33 +8,49 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon"; import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons"; import { TextIcon } from "../icons";
import { isDefaultFont } from "./FontPicker"; import { useExcalidrawSetAppState } from "../App";
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 isTriggerActive = useMemo( const setAppState = useExcalidrawSetAppState();
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily], const compactStyle = compactMode
); ? {
...MOBILE_ACTION_BUTTON_BG,
width: "2rem",
height: "2rem",
}
: {};
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */} <div data-openpopup="fontFamily" className="properties-trigger">
<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={isTriggerActive} active={isOpened}
// no-op onClick={() => {
onClick={() => {}} setAppState((appState) => ({
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 })} className={clsx("Shape", { fillable: false, active: props.checked })}
type="radio" type="radio"
icon={handIcon} icon={handIcon}
name="editor-current-shape" name="editor-current-shape"
@@ -2,11 +2,12 @@ import React from "react";
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common"; import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } 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,11 +28,24 @@ $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);
}
} }
} }
} }
+96 -34
View File
@@ -9,11 +9,10 @@ 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";
@@ -28,6 +27,11 @@ 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,
@@ -42,7 +46,9 @@ 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) {
@@ -50,14 +56,21 @@ 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", { arrowShortcut: getShortcutKey("A") }); return t("hints.arrowTool", {
shortcut: getTaggedShortcutKey("A"),
});
} }
return t("hints.linearElement"); return t("hints.linearElement");
} }
@@ -83,31 +96,51 @@ 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", {
: t("hints.resize"); shortcut_1: getTaggedShortcutKey("Shift"),
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") {
@@ -115,35 +148,59 @@ const getHints = ({
appState.selectionElement && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.editingTextElement && !appState.editingTextElement &&
!appState.editingLinearElement !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.editingLinearElement) { if (appState.selectedLinearElement?.isEditing) {
return appState.editingLinearElement.selectedPointsIndices return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected") ? t("hints.lineEditor_pointSelected", {
: t("hints.lineEditor_nothingSelected"); shortcut_1: getTaggedShortcutKey("Delete"),
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", {
: t("hints.lineEditor_info"); shortcut: getTaggedShortcutKey("Enter"),
})
: 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(
@@ -151,13 +208,13 @@ const getHints = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
) )
) { ) {
return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; return [bindTextToElement, createFlowchart];
} }
return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; return [bindTextToElement, createFlowchart];
} }
return t("hints.bindTextToElement"); return bindTextToElement;
} }
} }
} }
@@ -183,16 +240,21 @@ export const HintViewer = ({
} }
const hint = Array.isArray(hints) const hint = Array.isArray(hints)
? hints ? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
.map((hint) => { : hints;
return getShortcutKey(hint).replace(/\. ?$/, "");
}) const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
.join(". ") if (index % 2 === 1) {
: getShortcutKey(hints); const shortcutMatch =
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>{hint}</span> <span>{hintJSX}</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 } from "./App"; import { useDevice, useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
@@ -39,6 +39,7 @@ 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(
@@ -152,17 +153,16 @@ function Picker<T>({
); );
}; };
const isMobile = device.editor.isMobile;
return ( return (
<Popover.Content <Popover.Content
side={ side={isMobile ? "right" : "bottom"}
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start" align="start"
sideOffset={12} sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-popup)" }} style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
> >
<div <div
className={`picker`} className={`picker`}
+13 -3
View File
@@ -1,10 +1,20 @@
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { export const InlineIcon = ({
className,
icon,
size = "1em",
}: {
className?: string;
icon: React.ReactNode;
size?: string;
}) => {
return ( return (
<span <span
className={className}
style={{ style={{
width: "1em", width: size,
height: "100%",
margin: "0 0.5ex 0 0.5ex", margin: "0 0.5ex 0 0.5ex",
display: "inline-block", display: "inline-flex",
lineHeight: 0, lineHeight: 0,
verticalAlign: "middle", verticalAlign: "middle",
flex: "0 0 auto", flex: "0 0 auto",
@@ -24,6 +24,10 @@
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);
} }
+101 -37
View File
@@ -4,6 +4,7 @@ import React from "react";
import { import {
CLASSES, CLASSES,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE, TOOL_TYPE,
arrayToMap, arrayToMap,
capitalizeString, capitalizeString,
@@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import {
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";
@@ -86,6 +91,7 @@ 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"];
@@ -144,6 +150,7 @@ const LayerUI = ({
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
showExitZenModeBtn, showExitZenModeBtn,
renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
UIOptions, UIOptions,
@@ -157,6 +164,25 @@ 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);
@@ -209,31 +235,55 @@ const LayerUI = ({
</div> </div>
); );
const renderSelectedShapeActions = () => ( const renderSelectedShapeActions = () => {
<Section const isCompactMode = appState.stylesPanelMode === "compact";
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", { return (
"transition-left": appState.zenModeEnabled, <Section
})} heading="selectedShapeActions"
> className={clsx("selected-shape-actions zen-mode-transition", {
<Island "transition-left": appState.zenModeEnabled,
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 {isCompactMode ? (
appState={appState} <Island
elementsMap={app.scene.getNonDeletedElementsMap()} className={clsx("compact-shape-actions-island")}
renderAction={actionManager.renderAction} padding={0}
app={app} style={{
/> // we want to make sure this doesn't overflow so subtracting the
</Island> // approximate height of hamburgerMenu + footer
</Section> maxHeight: `${appState.height - 166}px`,
); }}
>
<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(
@@ -250,9 +300,19 @@ 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 gap={6} className={clsx("App-menu_top__left")}> <Stack.Col
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} <div
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" && (
@@ -262,17 +322,19 @@ const LayerUI = ({
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={4} align="start"> <Stack.Col gap={spacing.toolbarColGap} align="start">
<Stack.Row <Stack.Row
gap={1} gap={spacing.toolbarRowGap}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<Island <Island
padding={1} padding={spacing.islandPadding}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
"App-toolbar--compact":
appState.stylesPanelMode === "compact",
})} })}
> >
<HintViewer <HintViewer
@@ -282,7 +344,7 @@ const LayerUI = ({
app={app} app={app}
/> />
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={spacing.toolbarInnerRowGap}>
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
@@ -306,7 +368,7 @@ const LayerUI = ({
/> />
<ShapesSwitcher <ShapesSwitcher
appState={appState} setAppState={setAppState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions} UIOptions={UIOptions}
app={app} app={app}
@@ -316,7 +378,7 @@ const LayerUI = ({
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
marginLeft: 8, marginLeft: spacing.collabMarginLeft,
alignSelf: "center", alignSelf: "center",
height: "fit-content", height: "fit-content",
}} }}
@@ -344,6 +406,8 @@ 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",
}, },
)} )}
> >
@@ -418,7 +482,9 @@ const LayerUI = ({
}} }}
tab={DEFAULT_SIDEBAR.defaultTab} tab={DEFAULT_SIDEBAR.defaultTab}
> >
{t("toolBar.library")} {appState.stylesPanelMode === "full" &&
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 />}
@@ -518,13 +584,11 @@ 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,15 +133,10 @@
} }
.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 {
padding: 0; margin-left: auto;
position: absolute;
top: 1rem;
right: 0.75rem;
z-index: 1;
.dropdown-menu { .dropdown-menu {
top: 100%; top: 100%;
} }
+47 -1
View File
@@ -11,6 +11,11 @@ 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 {
@@ -266,11 +271,52 @@ 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], [onInsertElements, app],
); );
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
@@ -220,14 +220,6 @@ 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}
@@ -237,6 +229,14 @@ 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,24 +1,42 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
--container-padding-y: 1.5rem; --container-padding-y: 1rem;
--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.75rem; margin-bottom: 0.25rem;
} }
} }
.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);
@@ -34,7 +52,7 @@
overflow-y: auto; overflow-y: auto;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
justify-content: center; justify-content: flex-start;
margin: 0; margin: 0;
position: relative; position: relative;
@@ -51,26 +69,45 @@
} }
&__items { &__items {
// so that spinner is relative-positioned to this container
position: relative;
row-gap: 0.5rem; row-gap: 0.5rem;
padding: var(--container-padding-y) 0; padding: 1rem 0 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 {
@@ -79,6 +116,24 @@
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,11 +6,14 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common";
import { duplicateElements } from "@excalidraw/element"; import { duplicateElements } from "@excalidraw/element";
import { serializeLibraryAsJSON } from "../data/json"; import clsx from "clsx";
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";
@@ -27,6 +30,14 @@ 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,
@@ -64,6 +75,7 @@ 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);
@@ -75,6 +87,30 @@ 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],
@@ -85,23 +121,10 @@ 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(
@@ -115,10 +138,13 @@ 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 >= rangeStart && idx <= rangeEnd) || (idx >= minRange && idx <= maxRange) ||
selectedItemsMap.has(item.id) selectedItemsMap.has(item.id)
) { ) {
acc.push(item.id); acc.push(item.id);
@@ -127,7 +153,6 @@ export default function LibraryMenuItems({
}, },
[], [],
); );
onSelectItems(nextSelectedIds); onSelectItems(nextSelectedIds);
} else { } else {
onSelectItems([...selectedItems, id]); onSelectItems([...selectedItems, id]);
@@ -147,6 +172,14 @@ 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;
@@ -175,12 +208,17 @@ 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.excalidrawlib, MIME_TYPES.excalidrawlibIds,
serializeLibraryAsJSON(getInsertedElements(id)), JSON.stringify(data),
); );
}, },
[getInsertedElements], [selectedItems],
); );
const isItemSelected = useCallback( const isItemSelected = useCallback(
@@ -188,7 +226,6 @@ export default function LibraryMenuItems({
if (!id) { if (!id) {
return false; return false;
} }
return selectedItems.includes(id); return selectedItems.includes(id);
}, },
[selectedItems], [selectedItems],
@@ -208,10 +245,136 @@ export default function LibraryMenuItems({
); );
const itemsRenderedPerBatch = const itemsRenderedPerBatch =
svgCache.size >= libraryItems.length svgCache.size >=
(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"
@@ -223,127 +386,58 @@ export default function LibraryMenuItems({
: { borderBottom: 0 } : { borderBottom: 0 }
} }
> >
{!isLibraryEmpty && ( <div className="library-menu-items-header">
{!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",
marginBottom: 0, margin: IS_LIBRARY_EMPTY ? "auto" : 0,
}} }}
ref={libraryContainerRef} ref={libraryContainerRef}
> >
<> {isLoading && (
{!isLibraryEmpty && ( <div
<div className="library-menu-items-container__header"> style={{
{t("labels.personalLib")} position: "absolute",
</div> top: "var(--container-padding-y)",
)} right: "var(--container-padding-x)",
{isLoading && ( transform: "translateY(50%)",
<div }}
style={{ >
position: "absolute", <Spinner />
top: "var(--container-padding-y)", </div>
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}
{(publishedItems.length > 0 || {JSX_whenSearching}
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}
</>
{showBtn && ( {IS_LIBRARY_EMPTY && (
<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 = ( type LibraryOrPendingItem = readonly (
| LibraryItem | LibraryItem
| /* pending library item */ { | /* pending library item */ {
id: null; id: null;
@@ -18,12 +18,12 @@
} }
&--hover { &--hover {
border-color: var(--color-primary); background-color: var(--color-surface-mid);
} }
&:active:not(:has(.library-unit__checkbox:hover)),
&--selected { &--selected {
border-color: var(--color-primary); background-color: var(--color-surface-high);
border-width: 1px;
} }
&--skeleton { &--skeleton {
+2 -18
View File
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { memo, useEffect, useRef, useState } from "react"; import { memo, useRef, useState } from "react";
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
@@ -33,23 +33,7 @@ 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); const svg = useLibraryItemSvg(id, elements, svgCache, ref);
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;
+77 -132
View File
@@ -1,32 +1,23 @@
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 { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { MobileShapeActions } 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";
@@ -38,7 +29,6 @@ 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"];
@@ -46,9 +36,11 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: UIAppState, appState: UIAppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderTopLeftUI?: (
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;
@@ -59,14 +51,10 @@ export const MobileMenu = ({
elements, elements,
actionManager, actionManager,
setAppState, setAppState,
onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
renderCustomStats,
renderSidebars, renderSidebars,
device,
renderWelcomeScreen, renderWelcomeScreen,
UIOptions, UIOptions,
app, app,
@@ -76,141 +64,98 @@ export const MobileMenu = ({
MainMenuTunnel, MainMenuTunnel,
DefaultSidebarTriggerTunnel, DefaultSidebarTriggerTunnel,
} = useTunnels(); } = useTunnels();
const renderToolbar = () => { const renderAppTopBar = () => {
return ( const topRightUI = renderTopRightUI?.(true, appState) ?? (
<FixedSideContainer side="top" className="App-top-bar"> <DefaultSidebarTriggerTunnel.Out />
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />} );
<Section heading="shapes">
{(heading: React.ReactNode) => ( const topLeftUI = (
<Stack.Col gap={4} align="center"> <div className="excalidraw-ui-top-left">
<Stack.Row gap={1} className="App-toolbar-container"> {renderTopLeftUI?.(true, appState)}
<Island padding={1} className="App-toolbar App-toolbar--mobile"> <MainMenuTunnel.Out />
{heading} </div>
<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 ( return <div className="App-toolbar-content">{topLeftUI}</div>;
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
</div>
);
} }
return ( return (
<div className="App-toolbar-content"> <div
<MainMenuTunnel.Out /> className="App-toolbar-content"
{actionManager.renderAction("toggleEditMenu")} style={{
{actionManager.renderAction( display: "flex",
appState.multiElement ? "finalize" : "duplicateSelection", flexDirection: "row",
)} justifyContent: "space-between",
{actionManager.renderAction("deleteSelectedElements")} }}
<div> >
{actionManager.renderAction("undo")} {topLeftUI}
{actionManager.renderAction("redo")} {topRightUI}
</div>
</div> </div>
); );
}; };
const renderToolbar = () => {
return (
<MobileToolBar
app={app}
onHandToolToggle={onHandToolToggle}
setAppState={setAppState}
/>
);
};
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
{!appState.viewModeEnabled && {/* welcome screen, bottom bar, and top bar all have the same z-index */}
appState.openDialog?.name !== "elementLinkSelector" && {/* ordered in this reverse order so that top bar is on top */}
renderToolbar()} <div className="App-welcome-screen">
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
</div>
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2, marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}} }}
> >
<Island padding={0}> <MobileShapeActions
{appState.openMenu === "shape" && appState={appState}
!appState.viewModeEnabled && elementsMap={app.scene.getNonDeletedElementsMap()}
appState.openDialog?.name !== "elementLinkSelector" && renderAction={actionManager.renderAction}
showSelectedShapeActions(appState, elements) ? ( app={app}
<Section className="App-mobile-menu" heading="selectedShapeActions"> setAppState={setAppState}
<SelectedShapeActions />
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} <Island className="App-toolbar">
renderAction={actionManager.renderAction} {!appState.viewModeEnabled &&
app={app} appState.openDialog?.name !== "elementLinkSelector" &&
/> renderToolbar()}
</Section> {appState.scrolledOutside &&
) : null} !appState.openMenu &&
<footer className="App-toolbar"> !appState.openSidebar && (
{renderAppToolbar()} <button
{appState.scrolledOutside && type="button"
!appState.openMenu && className="scroll-back-to-content"
!appState.openSidebar && ( onClick={() => {
<button setAppState((appState) => ({
type="button" ...calculateScrollCenter(elements, appState),
className="scroll-back-to-content" }));
onClick={() => { }}
setAppState((appState) => ({ >
...calculateScrollCenter(elements, appState), {t("buttons.scrollBackToContent")}
})); </button>
}} )}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island> </Island>
</div> </div>
<FixedSideContainer side="top" className="App-top-bar">
{renderAppTopBar()}
</FixedSideContainer>
</> </>
); );
}; };
@@ -0,0 +1,78 @@
@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;
}
}
@@ -0,0 +1,474 @@
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>
);
};
+5 -1
View File
@@ -3,6 +3,8 @@ 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 = {
@@ -15,6 +17,7 @@ type Props = {
offsetTop?: number; offsetTop?: number;
viewportWidth?: number; viewportWidth?: number;
viewportHeight?: number; viewportHeight?: number;
className?: string;
}; };
export const Popover = ({ export const Popover = ({
@@ -27,6 +30,7 @@ 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);
@@ -146,7 +150,7 @@ export const Popover = ({
}, [onCloseRequest]); }, [onCloseRequest]);
return ( return (
<div className="popover" ref={popoverRef} tabIndex={-1}> <div className={clsx("popover", className)} ref={popoverRef} tabIndex={-1}>
{children} {children}
</div> </div>
); );
@@ -17,6 +17,7 @@ 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<
@@ -34,10 +35,13 @@ 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}>
@@ -45,25 +49,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={ side={isMobilePortrait ? "bottom" : "right"}
device.editor.isMobile && !device.viewport.isLandscape align={isMobilePortrait ? "center" : "start"}
? "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-popup)", zIndex: "var(--zIndex-ui-styles-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.cancel")} label={t("buttons.saveLibNames")}
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: 5; z-index: var(--zIndex-ui-library);
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -9,7 +9,13 @@ import React, {
useCallback, useCallback,
} from "react"; } from "react";
import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common"; import {
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";
@@ -137,7 +143,11 @@ export const SidebarInner = forwardRef(
return ( return (
<Island <Island
{...rest} {...rest}
className={clsx("sidebar", { "sidebar--docked": docked }, className)} className={clsx(
CLASSES.SIDEBAR,
{ "sidebar--docked": docked },
className,
)}
ref={islandRef} ref={islandRef}
> >
<SidebarPropsContext.Provider value={headerPropsRef.current}> <SidebarPropsContext.Provider value={headerPropsRef.current}>
@@ -30,7 +30,11 @@ 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({ openSidebar: isOpen ? { name, tab } : null }); setAppState({
openSidebar: isOpen ? { name, tab } : null,
openMenu: null,
openPopup: null,
});
onToggle?.(isOpen); onToggle?.(isOpen);
}} }}
checked={appState.openSidebar?.name === name} checked={appState.openSidebar?.name === name}
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/common"; import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
export const TTDDialogSubmitShortcut = () => { export const TTDDialogSubmitShortcut = () => {
return ( return (
@@ -1,12 +1,11 @@
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState } from "../App"; import { useExcalidrawSetAppState } from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu"; import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { brainIcon } from "../icons"; import { brainIcon } from "../icons";
import type { ReactNode } from "react"; import type { JSX, ReactNode } from "react";
import type { JSX } from "react";
export const TTDDialogTrigger = ({ export const TTDDialogTrigger = ({
children, children,
@@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({
children?: ReactNode; children?: ReactNode;
icon?: JSX.Element; icon?: JSX.Element;
}) => { }) => {
const { t } = useI18n();
const { TTDDialogTriggerTunnel } = useTunnels(); const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();

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