diff --git a/docker-compose.yml b/docker-compose.yml index b82053e57b..5beb3c15b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: excalidraw: build: diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index f7160c2c84..d4aba5d0d7 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -21,7 +21,6 @@ import { APP_NAME, EVENT, THEME, - TITLE_TIMEOUT, VERSION_TIMEOUT, debounce, getVersion, @@ -134,6 +133,7 @@ import { LibraryIndexedDBAdapter, LibraryLocalStorageMigrationAdapter, LocalData, + localStorageQuotaExceededAtom, } from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; @@ -791,11 +791,6 @@ const ExcalidrawWrapper = () => { } }; - const titleTimeout = setTimeout( - () => (document.title = APP_NAME), - TITLE_TIMEOUT, - ); - const syncData = debounce(() => { if (isTestEnv()) { return; @@ -886,7 +881,6 @@ const ExcalidrawWrapper = () => { visibilityChange, false, ); - clearTimeout(titleTimeout); }; }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); @@ -1026,6 +1020,8 @@ const ExcalidrawWrapper = () => { const isOffline = useAtomValue(isOfflineAtom); + const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom); + const onCollabDialogOpen = useCallback( () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), [setShareDialogState], @@ -1233,10 +1229,15 @@ const ExcalidrawWrapper = () => { {isCollaborating && isOffline && ( -
+
{t("alerts.collabOfflineWarning")}
)} + {localStorageQuotaExceeded && ( +
+ {t("alerts.localStorageQuotaExceeded")} +
+ )} {latestShareableLink && ( { 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) => { if (isImageElement(element) && element.status === "saved") { return newElementWith(element, { status: "pending" }); diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 9ad6dc9256..a2a930a1ac 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -27,6 +27,8 @@ import { get, } from "idb-keyval"; +import { appJotaiStore, atom } from "excalidraw-app/app-jotai"; + import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; @@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync"; const filesStore = createStore("files-db", "files-store"); +export const localStorageQuotaExceededAtom = atom(false); + class LocalFileManager extends FileManager { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { await entries(filesStore).then((entries) => { @@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { + const localStorageQuotaExceeded = appJotaiStore.get( + localStorageQuotaExceededAtom, + ); try { const _appState = clearAppStateForLocalStorage(appState); @@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = ( JSON.stringify(_appState), ); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); + if (localStorageQuotaExceeded) { + appJotaiStore.set(localStorageQuotaExceededAtom, false); + } } catch (error: any) { // Unable to access window.localStorage 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"; export class LocalData { diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index 568054f7ef..4e4c60b291 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -259,7 +259,9 @@ export const loadFromFirebase = async ( } const storedScene = docSnap.data() as FirebaseStoredScene; const elements = getSyncableElements( - restoreElements(await decryptElements(storedScene, roomKey), null), + restoreElements(await decryptElements(storedScene, roomKey), null, { + deleteInvisibleElements: true, + }), ); if (socket) { diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 75aa278779..cc7d5e8cc9 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -258,11 +258,16 @@ export const loadScene = async ( await importFromBackend(id, privateKey), localDataState?.appState, localDataState?.elements, - { repairBindings: true, refreshDimensions: false }, + { + repairBindings: true, + refreshDimensions: false, + deleteInvisibleElements: true, + }, ); } else { data = restore(localDataState || null, null, null, { repairBindings: true, + deleteInvisibleElements: true, }); } diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 7eac3e39a2..1c29d7220a 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -2,9 +2,7 @@ - - Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw - + Excalidraw Whiteboard { }, "isTouchScreen": false, "viewport": { - "isLandscape": false, + "isLandscape": true, "isMobile": true, }, } diff --git a/packages/common/src/binary-heap.ts b/packages/common/src/binary-heap.ts index 788a05c223..5abf484998 100644 --- a/packages/common/src/binary-heap.ts +++ b/packages/common/src/binary-heap.ts @@ -5,17 +5,18 @@ export class BinaryHeap { sinkDown(idx: number) { const node = this.content[idx]; + const nodeScore = this.scoreFunction(node); while (idx > 0) { const parentN = ((idx + 1) >> 1) - 1; const parent = this.content[parentN]; - if (this.scoreFunction(node) < this.scoreFunction(parent)) { - this.content[parentN] = node; + if (nodeScore < this.scoreFunction(parent)) { this.content[idx] = parent; idx = parentN; // TODO: Optimize } else { break; } } + this.content[idx] = node; } bubbleUp(idx: number) { @@ -24,35 +25,39 @@ export class BinaryHeap { const score = this.scoreFunction(node); while (true) { - const child2N = (idx + 1) << 1; - const child1N = child2N - 1; - let swap = null; - let child1Score = 0; + const child1N = ((idx + 1) << 1) - 1; + const child2N = child1N + 1; + let smallestIdx = idx; + let smallestScore = score; + // Check left child if (child1N < length) { - const child1 = this.content[child1N]; - child1Score = this.scoreFunction(child1); - if (child1Score < score) { - swap = child1N; + const child1Score = this.scoreFunction(this.content[child1N]); + if (child1Score < smallestScore) { + smallestIdx = child1N; + smallestScore = child1Score; } } + // Check right child if (child2N < length) { - const child2 = this.content[child2N]; - const child2Score = this.scoreFunction(child2); - if (child2Score < (swap === null ? score : child1Score)) { - swap = child2N; + const child2Score = this.scoreFunction(this.content[child2N]); + if (child2Score < smallestScore) { + smallestIdx = child2N; } } - if (swap !== null) { - this.content[idx] = this.content[swap]; - this.content[swap] = node; - idx = swap; // TODO: Optimize - } else { + if (smallestIdx === idx) { 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) { diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index c797c6e8c2..dfbb69aa97 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -18,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; export const isSafari = !isChrome && navigator.userAgent.indexOf("Safari") !== -1; export const isIOS = - /iPad|iPhone/.test(navigator.platform) || + /iPad|iPhone/i.test(navigator.platform) || // iPadOS 13+ (navigator.userAgent.includes("Mac") && "ontouchend" in document); // keeping function so it can be mocked in test export const 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 = typeof window !== "undefined" && "ResizeObserver" in window; @@ -118,10 +125,12 @@ export const ENV = { }; export const CLASSES = { + SIDEBAR: "sidebar", SHAPE_ACTIONS_MENU: "App-menu__left", ZOOM_ACTIONS: "zoom-actions", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", + SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", }; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; @@ -252,13 +261,20 @@ export const IMAGE_MIME_TYPES = { jfif: "image/jfif", } as const; -export const MIME_TYPES = { +export const STRING_MIME_TYPES = { text: "text/plain", html: "text/html", json: "application/json", // excalidraw data excalidraw: "application/vnd.excalidraw+json", + // LEGACY: fully-qualified library JSON data 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 "excalidraw.svg": "image/svg+xml", "excalidraw.png": "image/png", @@ -335,10 +351,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { // 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_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 export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; // ----------------------------------------------------------------------------- @@ -515,3 +541,10 @@ export enum UserIdleState { * the start and end points) */ 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; diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 1054960650..7bf73c6581 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -20,7 +20,8 @@ import { ENV, FONT_FAMILY, getFontFamilyFallbacks, - isDarwin, + isAndroid, + isIOS, WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; @@ -91,7 +92,8 @@ export const isWritableElement = ( (target instanceof HTMLInputElement && (target.type === "text" || target.type === "number" || - target.type === "password")); + target.type === "password" || + target.type === "search")); export const getFontFamilyString = ({ fontFamily, @@ -119,6 +121,11 @@ export const getFontString = ({ 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 = ( fn: (...args: T) => void, timeout: number, @@ -418,19 +425,6 @@ export const allowFullScreen = () => 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 = ( { clientX, clientY }: { clientX: number; clientY: number }, { @@ -1278,3 +1272,59 @@ export const reduceToCommonValue = ( 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; +}; diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index df0fd3e2d4..eaef257960 100644 --- a/packages/element/src/Scene.ts +++ b/packages/element/src/Scene.ts @@ -164,9 +164,14 @@ export class Scene { return this.frames; } - constructor(elements: ElementsMapOrArray | null = null) { + constructor( + elements: ElementsMapOrArray | null = null, + options?: { + skipValidation?: true; + }, + ) { if (elements) { - this.replaceAllElements(elements); + this.replaceAllElements(elements, options); } } @@ -263,12 +268,19 @@ export class Scene { 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 const _nextElements = toArray(nextElements); const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; - validateIndicesThrottled(_nextElements); + if (!options?.skipValidation) { + validateIndicesThrottled(_nextElements); + } this.elements = syncInvalidIndices(_nextElements); this.elementsMap.clear(); diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 9d97801f2e..fa1355309b 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = ( intersector, FIXED_BINDING_DISTANCE, ).sort(pointDistanceSq)[0]; + + if (!intersection) { + const anotherPoint = pointFrom( + !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 { intersection = intersectElementWithLineSegment( bindableElement, diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 2c07631a7a..0f3970db80 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -42,6 +42,7 @@ import { isBoundToContainer, isFreeDrawElement, isLinearElement, + isLineElement, isTextElement, } from "./typeChecks"; @@ -321,19 +322,42 @@ export const getElementLineSegments = ( if (shape.type === "polycurve") { const curves = shape.data; - const points = curves - .map((curve) => pointsOnBezierCurves(curve, 10)) - .flat(); - let i = 0; + const pointsOnCurves = curves.map((curve) => + pointsOnBezierCurves(curve, 10), + ); + const segments: LineSegment[] = []; - 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++; + + if ( + (isLineElement(element) && !element.polygon) || + isArrowElement(element) + ) { + for (const points of pointsOnCurves) { + 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++; + } + } + } 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; @@ -1126,7 +1150,9 @@ export interface BoundingBox { } export const getCommonBoundingBox = ( - elements: ExcalidrawElement[] | readonly NonDeleted[], + elements: + | readonly ExcalidrawElement[] + | readonly NonDeleted[], ): BoundingBox => { const [minX, minY, maxX, maxY] = getCommonBounds(elements); return { diff --git a/packages/element/src/comparisons.ts b/packages/element/src/comparisons.ts index 75fac889dc..c15e1ca4bc 100644 --- a/packages/element/src/comparisons.ts +++ b/packages/element/src/comparisons.ts @@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) => type === "freedraw"; 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) => type === "rectangle" || diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index bd428d8560..97b9403bcc 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -2,7 +2,6 @@ import { arrayToMap, arrayToObject, assertNever, - invariant, isDevEnv, isShallowEqual, isTestEnv, @@ -56,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; -import { Scene } from "./Scene"; - import { StoreSnapshot } from "./store"; +import { Scene } from "./Scene"; + import type { BindableProp, BindingProp } from "./binding"; import type { ElementUpdate } from "./mutateElement"; @@ -151,13 +150,27 @@ export class Delta { ); } + /** + * Merges two deltas into a new one. + */ + public static merge( + delta1: Delta, + delta2: Delta, + delta3: Delta = Delta.empty(), + ) { + return Delta.create( + { ...delta1.deleted, ...delta2.deleted, ...delta3.deleted }, + { ...delta1.inserted, ...delta2.inserted, ...delta3.inserted }, + ); + } + /** * Merges deleted and inserted object partials. */ public static mergeObjects( prev: T, added: T, - removed: T, + removed: T = {} as T, ) { const cloned = { ...prev }; @@ -497,6 +510,11 @@ export interface DeltaContainer { */ applyTo(previous: T, ...options: unknown[]): [T, boolean]; + /** + * Squashes the current delta with the given one. + */ + squash(delta: DeltaContainer): this; + /** * Checks whether all `Delta`s are empty. */ @@ -504,7 +522,11 @@ export interface DeltaContainer { } export class AppStateDelta implements DeltaContainer { - private constructor(public readonly delta: Delta) {} + private constructor(public delta: Delta) {} + + public static create(delta: Delta): AppStateDelta { + return new AppStateDelta(delta); + } public static calculate( prevAppState: T, @@ -535,76 +557,137 @@ export class AppStateDelta implements DeltaContainer { 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 = {}; + const mergedDeleted: Partial = {}; + + 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( appState: AppState, nextElements: SceneElementsMap, ): [AppState, boolean] { try { const { - selectedElementIds: removedSelectedElementIds = {}, - selectedGroupIds: removedSelectedGroupIds = {}, + selectedElementIds: deletedSelectedElementIds = {}, + selectedGroupIds: deletedSelectedGroupIds = {}, + lockedMultiSelections: deletedLockedMultiSelections = {}, } = this.delta.deleted; const { - selectedElementIds: addedSelectedElementIds = {}, - selectedGroupIds: addedSelectedGroupIds = {}, - selectedLinearElementId, - selectedLinearElementIsEditing, + selectedElementIds: insertedSelectedElementIds = {}, + selectedGroupIds: insertedSelectedGroupIds = {}, + lockedMultiSelections: insertedLockedMultiSelections = {}, + selectedLinearElement: insertedSelectedLinearElement, ...directlyApplicablePartial } = this.delta.inserted; const mergedSelectedElementIds = Delta.mergeObjects( appState.selectedElementIds, - addedSelectedElementIds, - removedSelectedElementIds, + insertedSelectedElementIds, + deletedSelectedElementIds, ); const mergedSelectedGroupIds = Delta.mergeObjects( appState.selectedGroupIds, - addedSelectedGroupIds, - removedSelectedGroupIds, + insertedSelectedGroupIds, + deletedSelectedGroupIds, ); - let selectedLinearElement = appState.selectedLinearElement; + const mergedLockedMultiSelections = Delta.mergeObjects( + appState.lockedMultiSelections, + insertedLockedMultiSelections, + deletedLockedMultiSelections, + ); - if (selectedLinearElementId === null) { - // Unselect linear element (visible change) - selectedLinearElement = null; - } else if ( - selectedLinearElementId && - nextElements.has(selectedLinearElementId) - ) { - selectedLinearElement = new LinearElementEditor( - nextElements.get( - selectedLinearElementId, - ) as NonDeleted, - nextElements, - selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false - ); - } - - if ( - // Value being 'null' is equivaluent to unknown in this case because it only gets set - // to null when 'selectedLinearElementId' is set to null - selectedLinearElementIsEditing != null - ) { - invariant( - selectedLinearElement, - `selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`, - ); - - selectedLinearElement = { - ...selectedLinearElement, - isEditing: selectedLinearElementIsEditing, - }; - } + const selectedLinearElement = + insertedSelectedLinearElement && + nextElements.has(insertedSelectedLinearElement.elementId) + ? new LinearElementEditor( + nextElements.get( + insertedSelectedLinearElement.elementId, + ) as NonDeleted, + nextElements, + insertedSelectedLinearElement.isEditing, + ) + : null; const nextAppState = { ...appState, ...directlyApplicablePartial, selectedElementIds: mergedSelectedElementIds, selectedGroupIds: mergedSelectedGroupIds, - selectedLinearElement, + lockedMultiSelections: mergedLockedMultiSelections, + selectedLinearElement: + typeof insertedSelectedLinearElement !== "undefined" + ? selectedLinearElement + : appState.selectedLinearElement, }; const constainsVisibleChanges = this.filterInvisibleChanges( @@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer { } break; - case "selectedLinearElementId": { - const appStateKey = AppStateDelta.convertToAppStateKey(key); - const linearElement = nextAppState[appStateKey]; + case "selectedLinearElement": + const nextLinearElement = nextAppState[key]; - if (!linearElement) { + if (!nextLinearElement) { // previously there was a linear element (assuming visible), now there is none visibleDifferenceFlag.value = true; } else { - const element = nextElements.get(linearElement.elementId); + const element = nextElements.get(nextLinearElement.elementId); if (element && !element.isDeleted) { // previously there wasn't a linear element, now there is one which is visible visibleDifferenceFlag.value = true; } else { // there was assigned a linear element now, but it's deleted - nextAppState[appStateKey] = null; + nextAppState[key] = null; } } break; - } - case "selectedLinearElementIsEditing": { - // Changes in editing state are always visible - const prevIsEditing = - prevAppState.selectedLinearElement?.isEditing ?? false; - const nextIsEditing = - nextAppState.selectedLinearElement?.isEditing ?? false; - - if (prevIsEditing !== nextIsEditing) { - visibleDifferenceFlag.value = true; - } - break; - } - case "lockedMultiSelections": { + case "lockedMultiSelections": const prevLockedUnits = prevAppState[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)) { visibleDifferenceFlag.value = true; } break; - } - case "activeLockedId": { + case "activeLockedId": const prevHitLockedId = prevAppState[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) { visibleDifferenceFlag.value = true; } break; - } - default: { + default: assertNever( key, `Unknown ObservedElementsAppState's key "${key}"`, true, ); - } } } } @@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer { return visibleDifferenceFlag.value; } - private static convertToAppStateKey( - key: keyof Pick, - ): keyof Pick { - switch (key) { - case "selectedLinearElementId": - return "selectedLinearElement"; - } - } - private static filterSelectedElements( selectedElementIds: AppState["selectedElementIds"], elements: SceneElementsMap, @@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer { editingGroupId, selectedGroupIds, selectedElementIds, - selectedLinearElementId, - selectedLinearElementIsEditing, + selectedLinearElement, croppingElementId, lockedMultiSelections, activeLockedId, @@ -926,12 +988,6 @@ export class AppStateDelta implements DeltaContainer { "lockedMultiSelections", (prevValue) => (prevValue ?? {}) as ValueOf, ); - Delta.diffObjects( - deleted, - inserted, - "activeLockedId", - (prevValue) => (prevValue ?? null) as ValueOf, - ); } catch (e) { // 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.`); @@ -960,12 +1016,13 @@ type ElementPartial = Omit>, "id" | "updated" | "seed">; export type ApplyToOptions = { - excludedProperties: Set; + excludedProperties?: Set; }; type ApplyToFlags = { containsVisibleDifference: boolean; containsZindexDifference: boolean; + applyDirection: "forward" | "backward" | undefined; }; /** @@ -1054,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer { inserted, }: Delta) => !!( - deleted.version && - inserted.version && // versions are required integers - Number.isInteger(deleted.version) && - Number.isInteger(inserted.version) && - // versions should be positive, zero included - deleted.version >= 0 && - inserted.version >= 0 && - // versions should never be the same - deleted.version !== inserted.version + ( + Number.isInteger(deleted.version) && + Number.isInteger(inserted.version) && + // versions should be positive, zero included + deleted.version! >= 0 && + inserted.version! >= 0 && + // 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( elementsDelta: ElementsDelta, type: "added" | "removed" | "updated", @@ -1074,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer { for (const [id, delta] of Object.entries(elementsDelta[type])) { if ( !this.satisfiesCommmonInvariants(delta) || + !this.satisfiesUniqueInvariants(elementsDelta, id) || !satifiesSpecialInvariants(delta) ) { console.error( @@ -1110,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer { const nextElement = nextElements.get(prevElement.id); if (!nextElement) { - const deleted = { ...prevElement, isDeleted: false } as ElementPartial; + const deleted = { ...prevElement } as ElementPartial; const inserted = { isDeleted: true, @@ -1124,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer { ElementsDelta.stripIrrelevantProps, ); - removed[prevElement.id] = delta; + if (!prevElement.isDeleted) { + removed[prevElement.id] = delta; + } else { + updated[prevElement.id] = delta; + } } } @@ -1140,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer { const inserted = { ...nextElement, - isDeleted: false, } as ElementPartial; const delta = Delta.create( @@ -1149,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer { 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; } @@ -1178,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer { continue; } - // making sure there are at least some changes - if (!Delta.isEmpty(delta)) { - updated[nextElement.id] = delta; - } + updated[nextElement.id] = delta; } } @@ -1196,8 +1268,8 @@ export class ElementsDelta implements DeltaContainer { const inverseInternal = (deltas: Record>) => { const inversedDeltas: Record> = {}; - for (const [id, delta] of Object.entries(deltas)) { - inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); + for (const [id, { inserted, deleted }] of Object.entries(deltas)) { + inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted }); } return inversedDeltas; @@ -1316,9 +1388,7 @@ export class ElementsDelta implements DeltaContainer { public applyTo( elements: SceneElementsMap, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, - options: ApplyToOptions = { - excludedProperties: new Set(), - }, + options?: ApplyToOptions, ): [SceneElementsMap, boolean] { let nextElements = new Map(elements) as SceneElementsMap; let changedElements: Map; @@ -1326,22 +1396,28 @@ export class ElementsDelta implements DeltaContainer { const flags: ApplyToFlags = { containsVisibleDifference: false, containsZindexDifference: false, + applyDirection: undefined, }; // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) try { const applyDeltas = ElementsDelta.createApplier( + elements, nextElements, snapshot, - options, flags, + options, ); const addedElements = applyDeltas(this.added); const removedElements = applyDeltas(this.removed); 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 changedElements = new Map([ @@ -1365,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer { } try { - // the following reorder performs also mutations, but only on new instances of changed elements - // (unless something goes really bad and it fallbacks to fixing all invalid indices) + // the following reorder performs mutations, but only on new instances of changed elements, + // unless something goes really bad and it fallbacks to fixing all invalid indices nextElements = ElementsDelta.reorderElements( nextElements, changedElements, flags, ); - // we don't have an up-to-date scene, as we can be just in the middle of applying history entry - // we also don't have a scene on the server - // so we are creating a temp scene just to query and mutate elements - const tempScene = new Scene(nextElements); - - ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements); - // Need ordered nextElements to avoid z-index binding issues - ElementsDelta.redrawBoundArrows(tempScene, changedElements); + ElementsDelta.redrawElements(nextElements, changedElements); } catch (e) { console.error( `Couldn't mutate elements after applying elements change`, @@ -1395,12 +1464,113 @@ export class ElementsDelta implements DeltaContainer { } } + public squash(delta: ElementsDelta): this { + if (delta.isEmpty()) { + return this; + } + + const { added, removed, updated } = delta; + + const mergeBoundElements = ( + prevDelta: Delta, + nextDelta: Delta, + ) => { + 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 = ( + prevElements: SceneElementsMap, nextElements: SceneElementsMap, snapshot: StoreSnapshot["elements"], - options: ApplyToOptions, flags: ApplyToFlags, + options?: ApplyToOptions, ) => (deltas: Record>) => { const getElement = ElementsDelta.createGetter( @@ -1413,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer { const element = getElement(id, delta.inserted); if (element) { - const newElement = ElementsDelta.applyDelta( + const nextElement = ElementsDelta.applyDelta( element, delta, - options, flags, + options, ); - nextElements.set(newElement.id, newElement); - acc.set(newElement.id, newElement); + nextElements.set(nextElement.id, nextElement); + 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; @@ -1466,8 +1647,8 @@ export class ElementsDelta implements DeltaContainer { private static applyDelta( element: OrderedExcalidrawElement, delta: Delta, - options: ApplyToOptions, flags: ApplyToFlags, + options?: ApplyToOptions, ) { const directlyApplicablePartial: Mutable = {}; @@ -1481,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer { continue; } - if (options.excludedProperties.has(key)) { + if (options?.excludedProperties?.has(key)) { continue; } @@ -1521,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer { delta.deleted.index !== delta.inserted.index; } - return newElementWith(element, directlyApplicablePartial); + return newElementWith(element, directlyApplicablePartial, true); } /** @@ -1561,6 +1742,7 @@ export class ElementsDelta implements DeltaContainer { private resolveConflicts( prevElements: SceneElementsMap, nextElements: SceneElementsMap, + applyDirection: "forward" | "backward" = "forward", ) { const nextAffectedElements = new Map(); const updater = ( @@ -1572,21 +1754,36 @@ export class ElementsDelta implements DeltaContainer { return; } + const prevElement = prevElements.get(element.id); + const nextVersion = + applyDirection === "forward" + ? nextElement.version + 1 + : nextElement.version - 1; + + const elementUpdates = updates as ElementUpdate; + 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 // so that we won't end up in an incosistent state in case we would fail in the middle of mutations affectedElement = newElementWith( nextElement, - updates as ElementUpdate, + { + ...elementUpdates, + version: nextVersion, + }, + true, ); } else { - affectedElement = mutateElement( - nextElement, - nextElements, - updates as ElementUpdate, - ); + affectedElement = mutateElement(nextElement, nextElements, { + ...elementUpdates, + // don't modify the version further, if it's already different + version: + prevElement?.version !== nextElement.version + ? nextElement.version + : nextVersion, + }); } nextAffectedElements.set(affectedElement.id, affectedElement); @@ -1624,25 +1821,12 @@ export class ElementsDelta implements DeltaContainer { Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), ); - // calculate complete deltas for affected elements, and assign them back to all the deltas - // technically we could do better here if perf. would become an issue - const { added, removed, updated } = ElementsDelta.calculate( - prevAffectedElements, - nextAffectedElements, + // calculate complete deltas for affected elements, and squash them back to the current deltas + this.squash( + // technically we could do better here if perf. would become an issue + ElementsDelta.calculate(prevAffectedElements, nextAffectedElements), ); - for (const [id, delta] of Object.entries(added)) { - this.added[id] = delta; - } - - for (const [id, delta] of Object.entries(removed)) { - this.removed[id] = delta; - } - - for (const [id, delta] of Object.entries(updated)) { - this.updated[id] = delta; - } - return nextAffectedElements; } @@ -1704,6 +1888,31 @@ export class ElementsDelta implements DeltaContainer { BindableElement.rebindAffected(nextElements, nextElement(), updater); } + public static redrawElements( + nextElements: SceneElementsMap, + changedElements: Map, + ) { + 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( scene: Scene, changed: Map, @@ -1758,6 +1967,7 @@ export class ElementsDelta implements DeltaContainer { ) { for (const element of changed.values()) { 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, { changedElements: changed, }); diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 0021851645..b988eb25bb 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -359,6 +359,12 @@ const handleSegmentRelease = ( null, ); + if (!restoredPoints || restoredPoints.length < 2) { + throw new Error( + "Property 'points' is required in the update returned by normalizeArrowElementUpdate()", + ); + } + const nextPoints: GlobalPoint[] = []; // First part of the arrow are the old points @@ -706,7 +712,7 @@ const handleEndpointDrag = ( endGlobalPoint: GlobalPoint, hoveredStartElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null, -) => { +): ElementUpdate => { let startIsSpecial = arrow.startIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null; const globalUpdatedPoints = updatedPoints.map((p, i) => @@ -741,8 +747,15 @@ const handleEndpointDrag = ( // Calculate the moving second point connection and add the start point { - const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; - const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; + const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); + 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 secondIsHorizontal = headingIsHorizontal( vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), @@ -801,10 +814,19 @@ const handleEndpointDrag = ( // Calculate the moving second to last point connection { - const secondToLastPoint = - globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; - const thirdToLastPoint = - globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)]; + const secondToLastPoint = globalUpdatedPoints.at( + globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), + ); + 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 secondIsHorizontal = headingForPointIsHorizontal( thirdToLastPoint, @@ -2071,16 +2093,7 @@ const normalizeArrowElementUpdate = ( nextFixedSegments: readonly FixedSegment[] | null, startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], -): { - points: LocalPoint[]; - x: number; - y: number; - width: number; - height: number; - fixedSegments: readonly FixedSegment[] | null; - startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; - endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; -} => { +): ElementUpdate => { const offsetX = global[0][0]; const offsetY = global[0][1]; let points = global.map((p) => diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 9bf5214d0f..d677859ad5 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => { // string hash function (using djb2). Not cryptographically secure, use only // 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 => { let hash: number = 5381; for (let i = 0; i < s.length; i++) { @@ -97,6 +100,7 @@ export * from "./image"; export * from "./linearElementEditor"; export * from "./mutateElement"; export * from "./newElement"; +export * from "./positionElementsOnGrid"; export * from "./renderElement"; export * from "./resizeElements"; export * from "./resizeTest"; diff --git a/packages/element/src/positionElementsOnGrid.ts b/packages/element/src/positionElementsOnGrid.ts new file mode 100644 index 0000000000..017ee1fd99 --- /dev/null +++ b/packages/element/src/positionElementsOnGrid.ts @@ -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 = ( + 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), + ); + }); + + // 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; +}; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 008d6afc4a..8c17863ee0 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -1,7 +1,14 @@ import rough from "roughjs/bin/rough"; import { getStroke } from "perfect-freehand"; -import { isRightAngleRads } from "@excalidraw/math"; +import { + type GlobalPoint, + isRightAngleRads, + lineSegment, + pointFrom, + pointRotateRads, + type Radians, +} from "@excalidraw/math"; import { BOUND_TEXT_PADDING, @@ -14,6 +21,7 @@ import { getFontString, isRTL, getVerticalOffset, + invariant, } from "@excalidraw/common"; import type { @@ -32,7 +40,7 @@ import type { InteractiveCanvasRenderConfig, } from "@excalidraw/excalidraw/scene/types"; -import { getElementAbsoluteCoords } from "./bounds"; +import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getUncroppedImageElement } from "./cropElement"; import { LinearElementEditor } from "./linearElementEditor"; import { @@ -1039,6 +1047,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { } export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { + return getSvgPathFromStroke(getFreedrawOutlinePoints(element)); +} + +export function getFreedrawOutlineAsSegments( + element: ExcalidrawFreeDrawElement, + points: [number, number][], + elementsMap: ElementsMap, +) { + const bounds = getElementBounds( + { + ...element, + angle: 0 as Radians, + }, + elementsMap, + ); + const center = pointFrom( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + ); + + invariant(points.length >= 2, "Freepath outline must have at least 2 points"); + + return points.slice(2).reduce( + (acc, curr) => { + acc.push( + lineSegment( + acc[acc.length - 1][1], + pointRotateRads( + pointFrom(curr[0] + element.x, curr[1] + element.y), + center, + element.angle, + ), + ), + ); + return acc; + }, + [ + lineSegment( + pointRotateRads( + pointFrom( + points[0][0] + element.x, + points[0][1] + element.y, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + points[1][0] + element.x, + points[1][1] + element.y, + ), + center, + element.angle, + ), + ), + ], + ); +} + +export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) { // If input points are empty (should they ever be?) return a dot const inputPoints = element.simulatePressure ? element.points @@ -1057,7 +1125,7 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup }; - return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); + return getStroke(inputPoints as number[][], options) as [number, number][]; } function med(A: number[], B: number[]) { diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index acb72b299b..8cfd807855 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -35,6 +35,7 @@ import { getContainerElement, handleBindTextResize, getBoundTextMaxWidth, + computeBoundTextPosition, } from "./textElement"; import { getMinTextElementWidth, @@ -225,7 +226,16 @@ const rotateSingleElement = ( scene.getElement(boundTextElementId); 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); if (boundText && !isArrowElement(element)) { + const { x, y } = computeBoundTextPosition( + element, + boundText, + elementsMap, + ); + scene.mutateElement(boundText, { - x: boundText.x + (rotatedCX - cx), - y: boundText.y + (rotatedCY - cy), + x, + y, angle: normalizeRadians((centerAngle + origAngle) as Radians), }); } diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 2bf70f5814..38235e752c 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[]; * Store which captures the observed changes and emits them as `StoreIncrement` events. */ export class Store { - // internally used by history + // for internal use by history public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); + // for public use as part of onIncrement API public readonly onStoreIncrementEmitter = new Emitter< [DurableIncrement | EphemeralIncrement] >(); @@ -239,7 +240,6 @@ export class Store { if (!storeDelta.isEmpty()) { const increment = new DurableIncrement(storeChange, storeDelta); - // Notify listeners with the increment this.onDurableIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment); } @@ -552,10 +552,26 @@ export class StoreDelta { public static load({ id, elements: { added, removed, updated }, + appState: { delta: appStateDelta }, }: DTO) { 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; } /** @@ -572,9 +588,7 @@ export class StoreDelta { delta: StoreDelta, elements: SceneElementsMap, appState: AppState, - options: ApplyToOptions = { - excludedProperties: new Set(), - }, + options?: ApplyToOptions, ): [SceneElementsMap, AppState, boolean] { const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( elements, @@ -613,6 +627,10 @@ export class StoreDelta { ); } + public static empty() { + return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty()); + } + public isEmpty() { return this.elements.isEmpty() && this.appState.isEmpty(); } @@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => { viewBackgroundColor: COLOR_PALETTE.white, selectedElementIds: {}, selectedGroupIds: {}, - selectedLinearElementId: null, - selectedLinearElementIsEditing: null, + selectedLinearElement: null, croppingElementId: null, activeLockedId: null, lockedMultiSelections: {}, @@ -998,14 +1015,12 @@ export const getObservedAppState = ( croppingElementId: appState.croppingElementId, activeLockedId: appState.activeLockedId, lockedMultiSelections: appState.lockedMultiSelections, - selectedLinearElementId: - (appState as AppState).selectedLinearElement?.elementId ?? - (appState as ObservedAppState).selectedLinearElementId ?? - null, - selectedLinearElementIsEditing: - (appState as AppState).selectedLinearElement?.isEditing ?? - (appState as ObservedAppState).selectedLinearElementIsEditing ?? - null, + selectedLinearElement: appState.selectedLinearElement + ? { + elementId: appState.selectedLinearElement.elementId, + isEditing: !!appState.selectedLinearElement.isEditing, + } + : null, }; Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 31347db240..523a8b8804 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -10,12 +10,12 @@ import { invariant, } from "@excalidraw/common"; +import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; + import type { AppState } from "@excalidraw/excalidraw/types"; import type { ExtractSetType } from "@excalidraw/common/utility-types"; -import type { Radians } from "@excalidraw/math"; - import { resetOriginalContainerCache, updateOriginalContainerCache, @@ -254,6 +254,26 @@ export const computeBoundTextPosition = ( x = 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 }; }; diff --git a/packages/element/src/transformHandles.ts b/packages/element/src/transformHandles.ts index 679937d4ae..b311e3af83 100644 --- a/packages/element/src/transformHandles.ts +++ b/packages/element/src/transformHandles.ts @@ -2,6 +2,7 @@ import { DEFAULT_TRANSFORM_HANDLE_SPACING, isAndroid, isIOS, + isMobileOrTablet, } from "@excalidraw/common"; import { pointFrom, pointRotateRads } from "@excalidraw/math"; @@ -326,7 +327,7 @@ export const getTransformHandles = ( ); }; -export const shouldShowBoundingBox = ( +export const hasBoundingBox = ( elements: readonly NonDeletedExcalidrawElement[], appState: InteractiveCanvasAppState, ) => { @@ -345,5 +346,7 @@ export const shouldShowBoundingBox = ( 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(); }; diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index a3da1c66d9..8690439782 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -10,6 +10,8 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; +import { LinearElementEditor } from "@excalidraw/element"; + import { getTransformHandles } from "../src/transformHandles"; import { getTextEditor, @@ -413,16 +415,12 @@ describe("element binding", () => { expect(arrow.endBinding?.elementId).toBe(rectRight.id); // Drag arrow off of bound rectangle range - const handles = getTransformHandles( + const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates( arrow, - h.state.zoom, - arrayToMap(h.elements), - "mouse", - ).se!; - + -1, + h.scene.getNonDeletedElementsMap(), + ); 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.moveTo(300, 400); mouse.up(); diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 4d56aac834..2b39b32df7 100644 --- a/packages/element/tests/delta.test.tsx +++ b/packages/element/tests/delta.test.tsx @@ -1,13 +1,345 @@ +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; + import type { ObservedAppState } from "@excalidraw/excalidraw/types"; 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("ensure stable delta properties order", () => { it("should maintain stable order for root properties", () => { const name = "untitled scene"; - const selectedLinearElementId = "id1" as LinearElementEditor["elementId"]; + const selectedLinearElement = { + elementId: "id1" as LinearElementEditor["elementId"], + isEditing: false, + }; const commonAppState = { viewBackgroundColor: "#ffffff", @@ -24,23 +356,23 @@ describe("AppStateDelta", () => { const prevAppState1: ObservedAppState = { ...commonAppState, name: "", - selectedLinearElementId: null, + selectedLinearElement: null, }; const nextAppState1: ObservedAppState = { ...commonAppState, name, - selectedLinearElementId, + selectedLinearElement, }; const prevAppState2: ObservedAppState = { - selectedLinearElementId: null, + selectedLinearElement: null, name: "", ...commonAppState, }; const nextAppState2: ObservedAppState = { - selectedLinearElementId, + selectedLinearElement, name, ...commonAppState, }; @@ -58,9 +390,7 @@ describe("AppStateDelta", () => { selectedGroupIds: {}, editingGroupId: null, croppingElementId: null, - selectedLinearElementId: null, - selectedLinearElementIsEditing: null, - editingLinearElementId: null, + selectedLinearElement: null, activeLockedId: null, lockedMultiSelections: {}, }; @@ -106,9 +436,7 @@ describe("AppStateDelta", () => { selectedElementIds: {}, editingGroupId: null, croppingElementId: null, - selectedLinearElementId: null, - selectedLinearElementIsEditing: null, - editingLinearElementId: null, + selectedLinearElement: null, activeLockedId: null, lockedMultiSelections: {}, }; @@ -149,4 +477,97 @@ describe("AppStateDelta", () => { 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>( + { + name: "untitled scene", + selectedElementIds: { id1: true }, + selectedGroupIds: {}, + lockedMultiSelections: { g1: true }, + }, + { + name: "titled scene", + selectedElementIds: { id2: true }, + selectedGroupIds: { g1: true }, + lockedMultiSelections: {}, + }, + ); + const delta2 = Delta.create>( + { + 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>( + { + 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 }, + }, + ), + ); + }); + }); }); diff --git a/packages/element/tests/textElement.test.ts b/packages/element/tests/textElement.test.ts index 5c10681a70..986854b985 100644 --- a/packages/element/tests/textElement.test.ts +++ b/packages/element/tests/textElement.test.ts @@ -1,13 +1,14 @@ import { getLineHeight } from "@excalidraw/common"; 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 { computeContainerDimensionForBoundText, getContainerCoords, getBoundTextMaxWidth, getBoundTextMaxHeight, + computeBoundTextPosition, } from "../src/textElement"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; @@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => { 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); + }); + }); +}); diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 63a887635b..2bbe4fab97 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -4,7 +4,7 @@ import { isFrameLikeElement } 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"; @@ -30,6 +30,8 @@ import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; +import { getShortcutKey } from "../shortcut"; + import { register } from "./register"; import type { AppClassProperties, AppState, UIAppState } from "../types"; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index b837ed9f81..4037adf98e 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -7,7 +7,6 @@ import { MIN_ZOOM, THEME, ZOOM_STEP, - getShortcutKey, updateActiveTool, CODES, KEYS, @@ -47,6 +46,7 @@ import { t } from "../i18n"; import { getNormalizedZoom } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; +import { getShortcutKey } from "../shortcut"; import { register } from "./register"; @@ -70,7 +70,7 @@ export const actionChangeViewBackgroundColor = register({ : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, appProps }) => { + PanelComponent: ({ elements, appState, updateData, appProps, data }) => { // FIXME move me to src/components/mainMenu/DefaultItems.tsx return ( ); }, @@ -122,7 +123,10 @@ export const actionClearCanvas = register({ pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" - ? { ...appState.activeTool, type: "selection" } + ? { + ...appState.activeTool, + type: app.state.preferredSelectionTool.type, + } : appState.activeTool, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, @@ -495,13 +499,13 @@ export const actionToggleEraserTool = register({ name: "toggleEraserTool", label: "toolBar.eraser", trackEvent: { category: "toolbar" }, - perform: (elements, appState) => { + perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; if (isEraserActive(appState)) { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: "selection", + type: app.state.preferredSelectionTool.type, }), lastActiveToolBeforeEraser: null, }); @@ -531,6 +535,9 @@ export const actionToggleLassoTool = register({ label: "toolBar.lasso", icon: LassoIcon, trackEvent: { category: "toolbar" }, + predicate: (elements, appState, props, app) => { + return app.state.preferredSelectionTool.type !== "lasso"; + }, perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index a9281ce84e..694f02b90c 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -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 { fixBindingsAfterDeletion } from "@excalidraw/element"; @@ -298,7 +302,9 @@ export const actionDeleteSelected = register({ elements: nextElements, appState: { ...nextAppState, - activeTool: updateActiveTool(appState, { type: "selection" }), + activeTool: updateActiveTool(appState, { + type: app.state.preferredSelectionTool.type, + }), multiElement: null, activeEmbeddable: null, selectedLinearElement: null, @@ -321,7 +327,15 @@ export const actionDeleteSelected = register({ title={t("labels.delete")} aria-label={t("labels.delete")} onClick={() => updateData(null)} - visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + disabled={ + !isSomeElementSelected(getNonDeletedElements(elements), appState) + } + style={{ + ...(appState.stylesPanelMode === "mobile" && + appState.openPopup !== "compactOtherProperties" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ), }); diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index f02906741c..88e085f1de 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -2,7 +2,7 @@ import { getNonDeletedElements } 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"; @@ -26,6 +26,8 @@ import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; +import { getShortcutKey } from "../shortcut"; + import { register } from "./register"; import type { AppClassProperties, AppState } from "../types"; diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index c1b2a9da42..69508a0228 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -1,8 +1,8 @@ import { DEFAULT_GRID_SIZE, KEYS, + MOBILE_ACTION_BUTTON_BG, arrayToMap, - getShortcutKey, } from "@excalidraw/common"; import { getNonDeletedElements } from "@excalidraw/element"; @@ -25,6 +25,7 @@ import { DuplicateIcon } from "../components/icons"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; +import { getShortcutKey } from "../shortcut"; import { register } from "./register"; @@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({ )}`} aria-label={t("labels.duplicateSelection")} onClick={() => updateData(null)} - visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} + disabled={ + !isSomeElementSelected(getNonDeletedElements(elements), appState) + } + style={{ + ...(appState.stylesPanelMode === "mobile" && + appState.openPopup !== "compactOtherProperties" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ), }); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9baeb0b6f0..4e7ae67919 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -5,7 +5,11 @@ import { bindOrUnbindLinearElement, isBindingEnabled, } from "@excalidraw/element/binding"; -import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; +import { + isValidPolygon, + LinearElementEditor, + newElementWith, +} from "@excalidraw/element"; import { isBindingElement, @@ -78,7 +82,14 @@ export const actionFinalize = register({ let newElements = elements; 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 - 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 { elements: newElements, @@ -117,7 +128,12 @@ export const actionFinalize = register({ return { elements: 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, appState: { ...appState, @@ -172,7 +188,12 @@ export const actionFinalize = register({ 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 - 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)) { @@ -240,13 +261,13 @@ export const actionFinalize = register({ if (appState.activeTool.type === "eraser") { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: "selection", + type: app.state.preferredSelectionTool.type, }), lastActiveToolBeforeEraser: null, }); } else { activeTool = updateActiveTool(appState, { - type: "selection", + type: app.state.preferredSelectionTool.type, }); } diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index dc0c22efdb..c72216b761 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -14,7 +14,7 @@ import { replaceAllElementsInFrame, } from "@excalidraw/element"; -import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common"; +import { KEYS, randomId, arrayToMap } from "@excalidraw/common"; import { getSelectedGroupIds, @@ -43,6 +43,8 @@ import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; +import { getShortcutKey } from "../shortcut"; + import { register } from "./register"; import type { AppClassProperties, AppState } from "../types"; diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index b948fe7d49..a1971f527c 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -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"; @@ -67,7 +73,7 @@ export const createUndoAction: ActionCreator = (history) => ({ ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, - PanelComponent: ({ updateData, data }) => { + PanelComponent: ({ appState, updateData, data }) => { const { isUndoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, new HistoryChangedEvent( @@ -85,6 +91,11 @@ export const createUndoAction: ActionCreator = (history) => ({ size={data?.size || "medium"} disabled={isUndoStackEmpty} data-testid="button-undo" + style={{ + ...(appState.stylesPanelMode === "mobile" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ); }, @@ -103,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({ keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), - PanelComponent: ({ updateData, data }) => { + PanelComponent: ({ appState, updateData, data }) => { const { isRedoStackEmpty } = useEmitter( history.onHistoryChangedEmitter, new HistoryChangedEvent( @@ -121,6 +132,11 @@ export const createRedoAction: ActionCreator = (history) => ({ size={data?.size || "medium"} disabled={isRedoStackEmpty} data-testid="button-redo" + style={{ + ...(appState.stylesPanelMode === "mobile" + ? MOBILE_ACTION_BUTTON_BG + : {}), + }} /> ); }, diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 9b18c64de8..8437ece8b5 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({ selectedElementIds: appState.selectedElementIds, })[0] as ExcalidrawLinearElement; + if (!selectedElement) { + return null; + } + const label = t( selectedElement.type === "arrow" ? "labels.lineEditor.editArrow" diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx index abb78f7f51..34b3326b57 100644 --- a/packages/excalidraw/actions/actionLink.tsx +++ b/packages/excalidraw/actions/actionLink.tsx @@ -1,6 +1,6 @@ import { isEmbeddableElement } from "@excalidraw/element"; -import { KEYS, getShortcutKey } from "@excalidraw/common"; +import { KEYS } from "@excalidraw/common"; import { CaptureUpdateAction } from "@excalidraw/element"; @@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { LinkIcon } from "../components/icons"; import { t } from "../i18n"; - import { getSelectedElements } from "../scene"; +import { getShortcutKey } from "../shortcut"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 2c6a774456..4cb95c2f0f 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -1,65 +1,11 @@ import { KEYS } from "@excalidraw/common"; -import { getNonDeletedElements } from "@excalidraw/element"; - -import { showSelectedShapeActions } from "@excalidraw/element"; - import { CaptureUpdateAction } from "@excalidraw/element"; -import { ToolButton } from "../components/ToolButton"; -import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; -import { t } from "../i18n"; +import { HelpIconThin } from "../components/icons"; 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 }) => ( - - ), -}); - -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 }) => ( - - ), -}); - export const actionShortcuts = register({ name: "toggleShortcuts", label: "welcomeScreen.defaults.helpHint", @@ -79,6 +25,8 @@ export const actionShortcuts = register({ : { name: "help", }, + openMenu: null, + openPopup: null, }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 63cfe76727..360220b1e2 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -17,7 +17,6 @@ import { randomInteger, arrayToMap, getFontFamilyString, - getShortcutKey, getLineHeight, isTransparent, reduceToCommonValue, @@ -137,6 +136,13 @@ import { isSomeElementSelected, } from "../scene"; +import { + withCaretPositionPreservation, + restoreCaretPosition, +} from "../hooks/useTextEditorFocus"; + +import { getShortcutKey } from "../shortcut"; + import { register } from "./register"; import type { AppClassProperties, AppState, Primitive } from "../types"; @@ -321,9 +327,11 @@ export const actionChangeStrokeColor = register({ : CaptureUpdateAction.EVENTUALLY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {appState.stylesPanelMode === "full" && ( + + )} ), @@ -398,9 +410,11 @@ export const actionChangeBackgroundColor = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => ( <> - + {appState.stylesPanelMode === "full" && ( + + )} ), @@ -518,7 +536,7 @@ export const actionChangeStrokeWidth = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
{t("labels.strokeWidth")}
@@ -575,7 +593,7 @@ export const actionChangeSloppiness = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
{t("labels.sloppiness")}
@@ -628,7 +646,7 @@ export const actionChangeStrokeStyle = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
{t("labels.strokeStyle")}
@@ -697,7 +715,7 @@ export const actionChangeFontSize = register({ perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, () => value, value); }, - PanelComponent: ({ elements, appState, updateData, app }) => ( + PanelComponent: ({ elements, appState, updateData, app, data }) => (
{t("labels.fontSize")}
@@ -756,7 +774,15 @@ export const actionChangeFontSize = register({ ? null : appState.currentItemFontSize || DEFAULT_FONT_SIZE, )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -1093,21 +1119,30 @@ export const actionChangeFontFamily = register({ }, []); return ( -
- {t("labels.fontFamily")} + <> + {appState.stylesPanelMode === "full" && ( + {t("labels.fontFamily")} + )} { - setBatchedData({ - openPopup: null, - currentHoveredFontFamily: null, - currentItemFontFamily: fontFamily, - }); - - // defensive clear so immediate close won't abuse the cached elements - cachedElementsRef.current.clear(); + withCaretPositionPreservation( + () => { + setBatchedData({ + openPopup: null, + currentHoveredFontFamily: null, + currentItemFontFamily: fontFamily, + }); + // defensive clear so immediate close won't abuse the cached elements + cachedElementsRef.current.clear(); + }, + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", + !!appState.editingTextElement, + ); }} onHover={(fontFamily) => { setBatchedData({ @@ -1164,29 +1199,33 @@ export const actionChangeFontFamily = register({ } setBatchedData({ + ...batchedData, openPopup: "fontFamily", }); } else { - // close, use the cache and clear it afterwards - const data = { - openPopup: null, + const fontFamilyData = { currentHoveredFontFamily: null, cachedElements: new Map(cachedElementsRef.current), resetAll: true, } as ChangeFontFamilyData; - if (isUnmounted.current) { - // in case the component was unmounted by the parent, trigger the update directly - updateData({ ...batchedData, ...data }); - } else { - setBatchedData(data); - } - + setBatchedData({ + ...fontFamilyData, + }); 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 + } } }} /> -
+ ); }, }); @@ -1225,8 +1264,9 @@ export const actionChangeTextAlign = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => { + PanelComponent: ({ elements, appState, updateData, app, data }) => { const elementsMap = app.scene.getNonDeletedElementsMap(); + return (
{t("labels.textAlign")} @@ -1275,7 +1315,15 @@ export const actionChangeTextAlign = register({ (hasSelection) => hasSelection ? null : appState.currentItemTextAlign, )} - onChange={(value) => updateData(value)} + onChange={(value) => { + withCaretPositionPreservation( + () => updateData(value), + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", + !!appState.editingTextElement, + data?.onPreventClose, + ); + }} />
@@ -1317,7 +1365,7 @@ export const actionChangeVerticalAlign = register({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ elements, appState, updateData, app }) => { + PanelComponent: ({ elements, appState, updateData, app, data }) => { return (
@@ -1367,7 +1415,15 @@ export const actionChangeVerticalAlign = register({ ) !== null, (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, + ); + }} />
@@ -1616,6 +1672,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 ( +
+ {renderAction("changeArrowhead")} + {renderAction("changeArrowType")} +
+ ); + }, +}); + export const actionChangeArrowType = register({ name: "changeArrowType", label: "Change arrow types", diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 62a6aa411f..1269ed23f6 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -1,4 +1,4 @@ -import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common"; +import { KEYS, CODES, isDarwin } from "@excalidraw/common"; import { moveOneLeft, @@ -16,6 +16,7 @@ import { SendToBackIcon, } from "../components/icons"; import { t } from "../i18n"; +import { getShortcutKey } from "../shortcut"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index f37747aebd..6b888e92d3 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -18,6 +18,7 @@ export { actionChangeFontFamily, actionChangeTextAlign, actionChangeVerticalAlign, + actionChangeArrowProperties, } from "./actionProperties"; export { @@ -43,11 +44,7 @@ export { } from "./actionExport"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; -export { - actionToggleCanvasMenu, - actionToggleEditMenu, - actionShortcuts, -} from "./actionMenu"; +export { actionShortcuts } from "./actionMenu"; export { actionGroup, actionUngroup } from "./actionGroup"; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index 1a13f1703c..ca593c3402 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -1,8 +1,9 @@ -import { isDarwin, getShortcutKey } from "@excalidraw/common"; +import { isDarwin } from "@excalidraw/common"; import type { SubtypeOf } from "@excalidraw/common/utility-types"; import { t } from "../i18n"; +import { getShortcutKey } from "../shortcut"; import type { ActionName } from "./types"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index e6f3631263..d533294d39 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -69,10 +69,9 @@ export type ActionName = | "changeStrokeStyle" | "changeArrowhead" | "changeArrowType" + | "changeArrowProperties" | "changeOpacity" | "changeFontSize" - | "toggleCanvasMenu" - | "toggleEditMenu" | "undo" | "redo" | "finalize" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 029b2cc61d..52535e9a36 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -55,6 +55,10 @@ export const getDefaultAppState = (): Omit< fromSelection: false, lastActiveTool: null, }, + preferredSelectionTool: { + type: "selection", + initialized: false, + }, penMode: false, penDetected: false, errorMessage: null, @@ -123,6 +127,7 @@ export const getDefaultAppState = (): Omit< searchMatches: null, lockedMultiSelections: {}, activeLockedId: null, + stylesPanelMode: "full", }; }; @@ -175,6 +180,7 @@ const APP_STATE_STORAGE_CONF = (< editingTextElement: { browser: false, export: false, server: false }, editingGroupId: { 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 }, penDetected: { browser: true, export: false, server: false }, errorMessage: { browser: false, export: false, server: false }, @@ -248,6 +254,7 @@ const APP_STATE_STORAGE_CONF = (< searchMatches: { browser: false, export: false, server: false }, lockedMultiSelections: { browser: true, export: true, server: true }, activeLockedId: { browser: false, export: false, server: false }, + stylesPanelMode: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 770bcc90e7..2115c3eff2 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -1,6 +1,7 @@ import { createPasteEvent, parseClipboard, + parseDataTransferEvent, serializeAsClipboardJSON, } from "./clipboard"; import { API } from "./tests/helpers/api"; @@ -13,7 +14,9 @@ describe("parseClipboard()", () => { text = "123"; clipboardData = await parseClipboard( - createPasteEvent({ types: { "text/plain": text } }), + await parseDataTransferEvent( + createPasteEvent({ types: { "text/plain": text } }), + ), ); expect(clipboardData.text).toBe(text); @@ -21,7 +24,9 @@ describe("parseClipboard()", () => { text = "[123]"; clipboardData = await parseClipboard( - createPasteEvent({ types: { "text/plain": text } }), + await parseDataTransferEvent( + createPasteEvent({ types: { "text/plain": text } }), + ), ); expect(clipboardData.text).toBe(text); @@ -29,7 +34,9 @@ describe("parseClipboard()", () => { text = JSON.stringify({ val: 42 }); clipboardData = await parseClipboard( - createPasteEvent({ types: { "text/plain": text } }), + await parseDataTransferEvent( + createPasteEvent({ types: { "text/plain": text } }), + ), ); expect(clipboardData.text).toBe(text); }); @@ -39,11 +46,13 @@ describe("parseClipboard()", () => { const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/plain": json, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/plain": json, + }, + }), + ), ); expect(clipboardData.elements).toEqual([rect]); }); @@ -56,21 +65,25 @@ describe("parseClipboard()", () => { // ------------------------------------------------------------------------- json = serializeAsClipboardJSON({ elements: [rect], files: null }); clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": json, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": json, + }, + }), + ), ); expect(clipboardData.elements).toEqual([rect]); // ------------------------------------------------------------------------- json = serializeAsClipboardJSON({ elements: [rect], files: null }); clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `
${json}
`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `
${json}
`, + }, + }), + ), ); expect(clipboardData.elements).toEqual([rect]); // ------------------------------------------------------------------------- @@ -80,11 +93,13 @@ describe("parseClipboard()", () => { let clipboardData; // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": ``, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": ``, + }, + }), + ), ); expect(clipboardData.mixedContent).toEqual([ { @@ -94,11 +109,13 @@ describe("parseClipboard()", () => { ]); // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `
`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `
`, + }, + }), + ), ); expect(clipboardData.mixedContent).toEqual([ { @@ -114,11 +131,13 @@ describe("parseClipboard()", () => { it("should parse text content alongside `src` urls out of text/html", async () => { const clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `hello
my friend!`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `hello
my friend!`, + }, + }), + ), ); expect(clipboardData.mixedContent).toEqual([ { @@ -141,14 +160,16 @@ describe("parseClipboard()", () => { let clipboardData; // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/plain": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ), ); expect(clipboardData.spreadsheet).toEqual({ title: "b", @@ -157,14 +178,16 @@ describe("parseClipboard()", () => { }); // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ), ); expect(clipboardData.spreadsheet).toEqual({ title: "b", @@ -173,19 +196,21 @@ describe("parseClipboard()", () => { }); // ------------------------------------------------------------------------- clipboardData = await parseClipboard( - createPasteEvent({ - types: { - "text/html": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": ` + +
ab
12
45
710
+ + `, + "text/plain": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ), ); expect(clipboardData.spreadsheet).toEqual({ title: "b", diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 99b7d41f4a..ae532a6c27 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -5,6 +5,7 @@ import { arrayToMap, isMemberOf, isPromiseLike, + EVENT, } from "@excalidraw/common"; import { mutateElement } from "@excalidraw/element"; @@ -16,15 +17,26 @@ import { 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 { ExcalidrawElement, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; 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 type { FileSystemHandle } from "./data/filesystem"; + import type { Spreadsheet } from "./charts"; import type { BinaryFiles } from "./types"; @@ -92,7 +104,7 @@ export const createPasteEvent = ({ console.warn("createPasteEvent: no types or files provided"); } - const event = new ClipboardEvent("paste", { + const event = new ClipboardEvent(EVENT.PASTE, { clipboardData: new DataTransfer(), }); @@ -101,10 +113,11 @@ export const createPasteEvent = ({ if (typeof value !== "string") { files = files || []; files.push(value); + event.clipboardData?.items.add(value); continue; } try { - event.clipboardData?.setData(type, value); + event.clipboardData?.items.add(value, type); if (event.clipboardData?.getData(type) !== value) { throw new Error(`Failed to set "${type}" as clipboardData item`); } @@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) { return result; } -const maybeParseHTMLPaste = ( - event: ClipboardEvent, +const maybeParseHTMLDataItem = ( + dataItem: ParsedDataTransferItemType, ): { type: "mixedContent"; value: PastedMixedContent } | null => { - const html = event.clipboardData?.getData(MIME_TYPES.html); - - if (!html) { - return null; - } + const html = dataItem.value; try { const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); @@ -332,18 +341,21 @@ export const readSystemClipboard = async () => { * Parses "paste" ClipboardEvent. */ const parseClipboardEventTextData = async ( - event: ClipboardEvent, + dataList: ParsedDataTranferList, isPlainPaste = false, ): Promise => { try { - const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); + const htmlItem = dataList.findByType(MIME_TYPES.html); + + const mixedContent = + !isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem); if (mixedContent) { if (mixedContent.value.every((item) => item.type === "text")) { return { type: "text", value: - event.clipboardData?.getData(MIME_TYPES.text) || + dataList.getData(MIME_TYPES.text) ?? mixedContent.value .map((item) => item.value) .join("\n") @@ -354,23 +366,156 @@ const parseClipboardEventTextData = async ( return mixedContent; } - const text = event.clipboardData?.getData(MIME_TYPES.text); - - return { type: "text", value: (text || "").trim() }; + return { + type: "text", + value: (dataList.getData(MIME_TYPES.text) || "").trim(), + }; } catch { return { type: "text", value: "" }; } }; +type AllowedParsedDataTransferItem = + | { + type: ValueOf; + kind: "file"; + file: File; + fileHandle: FileSystemHandle | null; + } + | { type: ValueOf; 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, +>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType | null { + return ( + this.find( + (item): item is ParsedDataTransferItemType => item.type === type, + ) || null + ); +}; +const getDataTransferItemData = function < + T extends ValueOf, +>( + this: ParsedDataTranferList, + type: T, +): + | ParsedDataTransferItemType>["value"] + | null { + const item = this.find( + ( + item, + ): item is ParsedDataTransferItemType> => + 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, +): Promise => { + 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 => { + 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((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. */ export const parseClipboard = async ( - event: ClipboardEvent, + dataList: ParsedDataTranferList, isPlainPaste = false, ): Promise => { const parsedEventData = await parseClipboardEventTextData( - event, + dataList, isPlainPaste, ); @@ -519,3 +664,14 @@ const copyTextViaExecCommand = (text: string | null) => { 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 + ); +}; diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 5826628de1..f97f3c7b6f 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -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); +} diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 5c9d59ada3..48ec4dc9a2 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; import { CLASSES, @@ -11,18 +12,16 @@ import { import { shouldAllowVerticalAlign, suppportsHorizontalAlign, -} from "@excalidraw/element"; - -import { hasBoundTextElement, isElbowArrow, isImageElement, isLinearElement, isTextElement, + isArrowElement, + hasStrokeColor, + toolIsArrow, } from "@excalidraw/element"; -import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; - import type { ExcalidrawElement, ExcalidrawElementType, @@ -46,15 +45,21 @@ import { hasStrokeWidth, } from "../scene"; -import { SHAPES } from "./shapes"; +import { getFormValue } from "../actions/actionProperties"; + +import { useTextEditorFocus } from "../hooks/useTextEditorFocus"; + +import { getToolbarTools } from "./shapes"; import "./Actions.scss"; -import { useDevice } from "./App"; +import { useDevice, useExcalidrawContainer } from "./App"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; +import { ToolPopover } from "./ToolPopover"; import { Tooltip } from "./Tooltip"; import DropdownMenu from "./dropdownMenu/DropdownMenu"; +import { PropertiesPopover } from "./PropertiesPopover"; import { EmbedIcon, extraToolsIcon, @@ -63,11 +68,32 @@ import { laserPointerToolIcon, MagicIcon, LassoIcon, + sharpArrowIcon, + roundArrowIcon, + elbowArrowIcon, + TextSizeIcon, + adjustmentsIcon, + DotsHorizontalIcon, + SelectionIcon, } from "./icons"; -import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; +import { Island } from "./Island"; + +import type { + AppClassProperties, + AppProps, + UIAppState, + Zoom, + AppState, +} from "../types"; import type { ActionManager } from "../actions/manager"; +// Common CSS class combinations +const PROPERTIES_CLASSES = clsx([ + CLASSES.SHAPE_ACTIONS_THEME_SCOPE, + "properties-content", +]); + export const canChangeStrokeColor = ( appState: UIAppState, targetElements: ExcalidrawElement[], @@ -280,22 +306,761 @@ export const SelectedShapeActions = ({ ); }; +const CombinedShapeProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; +}) => { + const showFillIcons = + (hasBackground(appState.activeTool.type) && + !isTransparent(appState.currentItemBackgroundColor)) || + targetElements.some( + (element) => + hasBackground(element.type) && !isTransparent(element.backgroundColor), + ); + + const shouldShowCombinedProperties = + targetElements.length > 0 || + (appState.activeTool.type !== "selection" && + appState.activeTool.type !== "eraser" && + appState.activeTool.type !== "hand" && + appState.activeTool.type !== "laser" && + appState.activeTool.type !== "lasso"); + const isOpen = appState.openPopup === "compactStrokeStyles"; + + if (!shouldShowCombinedProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactStrokeStyles" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > +
+ {showFillIcons && renderAction("changeFillStyle")} + {(hasStrokeWidth(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeWidth(element.type), + )) && + renderAction("changeStrokeWidth")} + {(hasStrokeStyle(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeStyle(element.type), + )) && ( + <> + {renderAction("changeStrokeStyle")} + {renderAction("changeSloppiness")} + + )} + {(canChangeRoundness(appState.activeTool.type) || + targetElements.some((element) => + canChangeRoundness(element.type), + )) && + renderAction("changeRoundness")} + {renderAction("changeOpacity")} +
+
+ )} +
+
+ ); +}; + +const CombinedArrowProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + app, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; +}) => { + const showShowArrowProperties = + toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type)); + const isOpen = appState.openPopup === "compactArrowProperties"; + + if (!showShowArrowProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactArrowProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > + {renderAction("changeArrowProperties")} + + )} + +
+ ); +}; + +const CombinedTextProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + elementsMap, +}: { + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + targetElements: ExcalidrawElement[]; + container: HTMLDivElement | null; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; +}) => { + const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); + const isOpen = appState.openPopup === "compactTextProperties"; + + return ( +
+ { + if (open) { + if (appState.editingTextElement) { + saveCaretPosition(); + } + setAppState({ openPopup: "compactTextProperties" }); + } else { + setAppState({ openPopup: null }); + if (appState.editingTextElement) { + restoreCaretPosition(); + } + } + }} + > + + + + {appState.openPopup === "compactTextProperties" && ( + { + // Refocus text editor when popover closes with caret restoration + if (appState.editingTextElement) { + restoreCaretPosition(); + } + }} + > +
+ {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize")} + {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} +
+
+ )} +
+
+ ); +}; + +const CombinedExtraActions = ({ + appState, + renderAction, + targetElements, + setAppState, + container, + app, + showDuplicate, + showDelete, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; + showDuplicate?: boolean; + showDelete?: boolean; +}) => { + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); + const showCropEditorAction = + !appState.croppingElementId && + targetElements.length === 1 && + isImageElement(targetElements[0]); + const showLinkIcon = targetElements.length === 1; + const showAlignActions = alignActionsPredicate(appState, app); + let isSingleElementBoundContainer = false; + if ( + targetElements.length === 2 && + (hasBoundTextElement(targetElements[0]) || + hasBoundTextElement(targetElements[1])) + ) { + isSingleElementBoundContainer = true; + } + + const isRTL = document.documentElement.getAttribute("dir") === "rtl"; + const isOpen = appState.openPopup === "compactOtherProperties"; + + if (isEditingTextOrNewElement || targetElements.length === 0) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactOtherProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > +
+
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} +
+
+ + {showAlignActions && !isSingleElementBoundContainer && ( +
+ {t("labels.align")} +
+ {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} +
+
+ {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} +
+
+
+ )} +
+ {t("labels.actions")} +
+ {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} + {showDuplicate && renderAction("duplicateSelection")} + {showDelete && renderAction("deleteSelectedElements")} +
+
+
+
+ )} +
+
+ ); +}; + +const LinearEditorAction = ({ + appState, + renderAction, + targetElements, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; +}) => { + const showLineEditorAction = + !appState.selectedLinearElement?.isEditing && + targetElements.length === 1 && + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); + + if (!showLineEditorAction) { + return null; + } + + return ( +
+ {renderAction("toggleLinearEditor")} +
+ ); +}; + +export const CompactShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const { container } = useExcalidrawContainer(); + + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); + + const showLineEditorAction = + !appState.selectedLinearElement?.isEditing && + targetElements.length === 1 && + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); + + return ( +
+ {/* Stroke Color */} + {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + + {/* Background Color */} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + + + + {/* Linear Editor */} + {showLineEditorAction && ( +
+ {renderAction("toggleLinearEditor")} +
+ )} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+ + + )} + + {/* Dedicated Copy Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("duplicateSelection")} +
+ )} + + {/* Dedicated Delete Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} + + +
+ ); +}; + +export const MobileShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const { container } = useExcalidrawContainer(); + const mobileActionsRef = useRef(null); + + const ACTIONS_WIDTH = + mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0; + + // 7 actions + 2 for undo/redo + const MIN_ACTIONS = 9; + + const GAP = 6; + const WIDTH = 32; + + const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP; + + const ADDITIONAL_WIDTH = WIDTH + GAP; + + const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH; + const showDuplicateOutside = + ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH; + + return ( + +
+ {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + {/* Combined Arrow Properties */} + + {/* Linear Editor */} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+ + + )} + + {/* Combined Other Actions */} + +
+
+
{renderAction("undo")}
+
{renderAction("redo")}
+ {showDuplicateOutside && ( +
+ {renderAction("duplicateSelection")} +
+ )} + {showDeleteOutside && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} +
+
+ ); +}; + export const ShapesSwitcher = ({ activeTool, - appState, + setAppState, app, UIOptions, }: { activeTool: UIAppState["activeTool"]; - appState: UIAppState; + setAppState: React.Component["setState"]; app: AppClassProperties; UIOptions: AppProps["UIOptions"]; }) => { const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); + const SELECTION_TOOLS = [ + { + type: "selection", + icon: SelectionIcon, + title: capitalizeString(t("toolBar.selection")), + }, + { + type: "lasso", + icon: LassoIcon, + title: capitalizeString(t("toolBar.lasso")), + }, + ] as const; + const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; - const lassoToolSelected = activeTool.type === "lasso"; + const lassoToolSelected = + app.state.stylesPanelMode === "full" && + activeTool.type === "lasso" && + app.state.preferredSelectionTool.type !== "lasso"; const embeddableToolSelected = activeTool.type === "embeddable"; @@ -303,63 +1068,102 @@ export const ShapesSwitcher = ({ return ( <> - {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { - if ( - UIOptions.tools?.[ - value as Extract - ] === false - ) { - return null; - } + {getToolbarTools(app).map( + ({ value, icon, key, numericKey, fillable }, index) => { + if ( + UIOptions.tools?.[ + value as Extract< + typeof value, + keyof AppProps["UIOptions"]["tools"] + > + ] === false + ) { + return null; + } - const label = t(`toolBar.${value}`); - const letter = - key && capitalizeString(typeof key === "string" ? key : key[0]); - const shortcut = letter - ? `${letter} ${t("helpDialog.or")} ${numericKey}` - : `${numericKey}`; - - return ( - { - if (!appState.penDetected && pointerType === "pen") { - app.togglePenMode(true); - } - - if (value === "selection") { - if (appState.activeTool.type === "selection") { - app.setActiveTool({ type: "lasso" }); - } else { - app.setActiveTool({ type: "selection" }); + const label = t(`toolBar.${value}`); + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter + ? `${letter} ${t("helpDialog.or")} ${numericKey}` + : `${numericKey}`; + // when in compact styles panel mode (tablet) + // use a ToolPopover for selection/lasso toggle as well + if ( + (value === "selection" || value === "lasso") && + app.state.stylesPanelMode === "compact" + ) { + return ( + { + 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] } - } - }} - onChange={({ pointerType }) => { - if (appState.activeTool.type !== value) { - trackEvent("toolbar", value, "ui"); - } - if (value === "image") { - app.setActiveTool({ - type: value, - }); - } else { - app.setActiveTool({ type: value }); - } - }} - /> - ); - })} + fillable={activeTool.type === "selection"} + /> + ); + } + + return ( + { + if (!app.state.penDetected && pointerType === "pen") { + app.togglePenMode(true); + } + + if (value === "selection") { + if (app.state.activeTool.type === "selection") { + app.setActiveTool({ type: "lasso" }); + } else { + app.setActiveTool({ type: "selection" }); + } + } + }} + onChange={({ pointerType }) => { + if (app.state.activeTool.type !== value) { + trackEvent("toolbar", value, "ui"); + } + if (value === "image") { + app.setActiveTool({ + type: value, + }); + } else { + app.setActiveTool({ type: value }); + } + }} + /> + ); + }, + )}
@@ -374,7 +1178,10 @@ export const ShapesSwitcher = ({ // on top of it (laserToolSelected && !app.props.isCollaborating), })} - onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} + onToggle={() => { + setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen); + setAppState({ openMenu: null, openPopup: null }); + }} title={t("toolBar.extraTools")} > {frameToolSelected @@ -418,14 +1225,16 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} - app.setActiveTool({ type: "lasso" })} - icon={LassoIcon} - data-testid="toolbar-lasso" - selected={lassoToolSelected} - > - {t("toolBar.lasso")} - + {app.state.stylesPanelMode === "full" && ( + app.setActiveTool({ type: "lasso" })} + icon={LassoIcon} + data-testid="toolbar-lasso" + selected={lassoToolSelected} + > + {t("toolBar.lasso")} + + )}
Generate
@@ -438,16 +1247,14 @@ export const ShapesSwitcher = ({ {t("toolBar.mermaidToExcalidraw")} {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( - <> - app.onMagicframeToolSelect()} - icon={MagicIcon} - data-testid="toolbar-magicframe" - > - {t("toolBar.magicframe")} - AI - - + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + > + {t("toolBar.magicframe")} + AI + )}
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index cb40137d8d..0f8fd85e0f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -41,9 +41,6 @@ import { LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, MIME_TYPES, - MQ_MAX_HEIGHT_LANDSCAPE, - MQ_MAX_WIDTH_LANDSCAPE, - MQ_MAX_WIDTH_PORTRAIT, MQ_RIGHT_SIDEBAR_MIN_WIDTH, POINTER_BUTTON, ROUNDNESS, @@ -83,7 +80,6 @@ import { wrapEvent, updateObject, updateActiveTool, - getShortcutKey, isTransparent, easeToValuesRAF, muteFSAbortError, @@ -101,6 +97,13 @@ import { CLASSES, Emitter, MINIMUM_ARROW_SIZE, + DOUBLE_TAP_POSITION_THRESHOLD, + isMobileOrTablet, + MQ_MAX_MOBILE, + MQ_MIN_TABLET, + MQ_MAX_TABLET, + MQ_MAX_HEIGHT_LANDSCAPE, + MQ_MAX_WIDTH_LANDSCAPE, } from "@excalidraw/common"; import { @@ -169,7 +172,7 @@ import { getContainerElement, isValidTextContainer, redrawTextBoundingBox, - shouldShowBoundingBox, + hasBoundingBox, getFrameChildren, isCursorInFrame, addElementsToFrame, @@ -233,6 +236,9 @@ import { hitElementBoundingBox, isLineElement, isSimpleArrow, + StoreDelta, + type ApplyToOptions, + positionElementsOnGrid, } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -259,6 +265,7 @@ import type { MagicGenerationData, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, + SceneElementsMap, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -318,7 +325,13 @@ import { isEraserActive, isHandToolActive, } from "../appState"; -import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; +import { + copyTextToSystemClipboard, + parseClipboard, + parseDataTransferEvent, + type ParsedDataTransferFile, +} from "../clipboard"; + import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restore, restoreElements } from "../data/restore"; @@ -340,7 +353,6 @@ import { generateIdFromFile, getDataURL, getDataURL_sync, - getFileFromEvent, ImageURLToFile, isImageFileHandle, isSupportedImageFile, @@ -398,6 +410,8 @@ import { } from "../scene/scrollConstraints"; import { EraserTrail } from "../eraser"; +import { getShortcutKey } from "../shortcut"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -427,12 +441,14 @@ import { findShapeByKey } from "./shapes"; import UnlockPopup from "./UnlockPopup"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { RenderInteractiveSceneCallback, ScrollBars, } from "../scene/types"; -import type { PastedMixedContent } from "../clipboard"; +import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ExportedElements } from "../data"; import type { ContextMenuItems } from "./ContextMenu"; import type { FileSystemHandle } from "../data/filesystem"; @@ -535,6 +551,7 @@ export const useExcalidrawActionManager = () => let didTapTwice: boolean = false; let tappedTwiceTimer = 0; +let firstTapPosition: { x: number; y: number } | null = null; let isHoldingSpace: boolean = false; let isPanning: boolean = false; let isDraggingScrollBar: boolean = false; @@ -710,6 +727,7 @@ class App extends React.Component { if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, + applyDeltas: this.applyDeltas, mutateElement: this.mutateElement, updateLibrary: this.library.updateLibrary, addFiles: this.addFiles, @@ -1519,7 +1537,7 @@ class App extends React.Component { public render() { const selectedElements = this.scene.getSelectedElements(this.state); - const { renderTopRightUI, renderCustomStats } = this.props; + const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props; const sceneNonce = this.scene.getSceneNonce(); const { elementsMap, visibleElements } = @@ -1605,6 +1623,7 @@ class App extends React.Component { onPenModeToggle={this.togglePenMode} onHandToolToggle={this.onHandToolToggle} langCode={getLanguage().code} + renderTopLeftUI={renderTopLeftUI} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} showExitZenModeBtn={ @@ -1616,7 +1635,8 @@ class App extends React.Component { renderWelcomeScreen={ !this.state.isLoading && this.state.showWelcomeScreen && - this.state.activeTool.type === "selection" && + this.state.activeTool.type === + this.state.preferredSelectionTool.type && !this.state.zenModeEnabled && !this.scene.getElementsIncludingDeleted().length } @@ -2356,7 +2376,19 @@ class App extends React.Component { }, }; } - const scene = restore(initialData, null, null, { repairBindings: true }); + const scene = restore(initialData, null, null, { + repairBindings: true, + deleteInvisibleElements: true, + }); + const activeTool = scene.appState.activeTool; + + if (!scene.appState.preferredSelectionTool.initialized) { + scene.appState.preferredSelectionTool = { + type: this.device.editor.isMobile ? "lasso" : "selection", + initialized: true, + }; + } + scene.appState = { ...scene.appState, theme: this.props.theme || scene.appState.theme, @@ -2366,12 +2398,18 @@ class App extends React.Component { // with a library install link, which should auto-open the library) openSidebar: scene.appState?.openSidebar || this.state.openSidebar, activeTool: - scene.appState.activeTool.type === "image" - ? { ...scene.appState.activeTool, type: "selection" } + activeTool.type === "image" || + activeTool.type === "lasso" || + activeTool.type === "selection" + ? { + ...activeTool, + type: scene.appState.preferredSelectionTool.type, + } : scene.appState.activeTool, isLoading: false, toast: this.state.toast, }; + if (this.props.scrollConstraints) { scene.appState = { ...scene.appState, @@ -2414,25 +2452,32 @@ class App extends React.Component { private isMobileBreakpoint = (width: number, height: number) => { return ( - width < MQ_MAX_WIDTH_PORTRAIT || + width <= MQ_MAX_MOBILE || (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) ); }; + private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { + const minSide = Math.min(editorWidth, editorHeight); + const maxSide = Math.max(editorWidth, editorHeight); + + return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET; + }; + private refreshViewportBreakpoints = () => { const container = this.excalidrawContainerRef.current; if (!container) { return; } - const { clientWidth: viewportWidth, clientHeight: viewportHeight } = - document.body; + const { width: editorWidth, height: editorHeight } = + container.getBoundingClientRect(); const prevViewportState = this.device.viewport; const nextViewportState = updateObject(prevViewportState, { - isLandscape: viewportWidth > viewportHeight, - isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight), + isLandscape: editorWidth > editorHeight, + isMobile: this.isMobileBreakpoint(editorWidth, editorHeight), }); if (prevViewportState !== nextViewportState) { @@ -2463,6 +2508,30 @@ class App extends React.Component { canFitSidebar: editorWidth > sidebarBreakpoint, }); + const stylesPanelMode = + // NOTE: we could also remove the isMobileOrTablet check here and + // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP) + // but not too narrow (> MQ_MAX_WIDTH_MOBILE) + this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() + ? "compact" + : this.isMobileBreakpoint(editorWidth, editorHeight) + ? "mobile" + : "full"; + + // also check if we need to update the app state + this.setState((prevState) => ({ + stylesPanelMode, + // reset to box selection mode if the UI changes to full + // where you'd not be able to change the mode yourself currently + preferredSelectionTool: + stylesPanelMode === "full" + ? { + type: "selection", + initialized: true, + } + : prevState.preferredSelectionTool, + })); + if (prevEditorState !== nextEditorState) { this.device = { ...this.device, editor: nextEditorState }; return true; @@ -3010,6 +3079,7 @@ class App extends React.Component { private static resetTapTwice() { didTapTwice = false; + firstTapPosition = null; } private onTouchStart = (event: TouchEvent) => { @@ -3020,6 +3090,13 @@ class App extends React.Component { if (!didTapTwice) { didTapTwice = true; + + if (event.touches.length === 1) { + firstTapPosition = { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }; + } clearTimeout(tappedTwiceTimer); tappedTwiceTimer = window.setTimeout( App.resetTapTwice, @@ -3027,15 +3104,29 @@ class App extends React.Component { ); return; } - // insert text only if we tapped twice with a single finger + + // insert text only if we tapped twice with a single finger at approximately the same position // event.touches.length === 1 will also prevent inserting text when user's zooming - if (didTapTwice && event.touches.length === 1) { + if (didTapTwice && event.touches.length === 1 && firstTapPosition) { const touch = event.touches[0]; - // @ts-ignore - this.handleCanvasDoubleClick({ - clientX: touch.clientX, - clientY: touch.clientY, - }); + const distance = pointDistance( + pointFrom(touch.clientX, touch.clientY), + pointFrom(firstTapPosition.x, firstTapPosition.y), + ); + + // only create text if the second tap is within the threshold of the first tap + // this prevents accidental text creation during dragging/selection + if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) { + // end lasso trail and deselect elements just in case + this.lassoTrail.endPath(); + this.deselectElements(); + + // @ts-ignore + this.handleCanvasDoubleClick({ + clientX: touch.clientX, + clientY: touch.clientY, + }); + } didTapTwice = false; clearTimeout(tappedTwiceTimer); } @@ -3063,7 +3154,166 @@ class App extends React.Component { } }; - // TODO: this is so spaghetti, we should refactor it and cover it with tests + // TODO: Cover with tests + private async insertClipboardContent( + data: ClipboardData, + dataTransferFiles: ParsedDataTransferFile[], + isPlainPaste: boolean, + ) { + const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, + this.state, + ); + + // ------------------- Error ------------------- + if (data.errorMessage) { + this.setState({ errorMessage: data.errorMessage }); + return; + } + + // ------------------- Mixed content with no files ------------------- + if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) { + await this.addElementsFromMixedContentPaste(data.mixedContent, { + isPlainPaste, + sceneX, + sceneY, + }); + return; + } + + // ------------------- Spreadsheet ------------------- + if (data.spreadsheet && !isPlainPaste) { + this.setState({ + pasteDialog: { + data: data.spreadsheet, + shown: true, + }, + }); + return; + } + + // ------------------- Images or SVG code ------------------- + const imageFiles = dataTransferFiles.map((data) => data.file); + + if (imageFiles.length === 0 && data.text && !isPlainPaste) { + const trimmedText = data.text.trim(); + if (trimmedText.startsWith("")) { + // ignore SVG validation/normalization which will be done during image + // initialization + imageFiles.push(SVGStringToFile(trimmedText)); + } + } + + if (imageFiles.length > 0) { + if (this.isToolSupported("image")) { + await this.insertImages(imageFiles, sceneX, sceneY); + } else { + this.setState({ errorMessage: t("errors.imageToolNotSupported") }); + } + return; + } + + // ------------------- Elements ------------------- + if (data.elements) { + const elements = ( + data.programmaticAPI + ? convertToExcalidrawElements( + data.elements as ExcalidrawElementSkeleton[], + ) + : data.elements + ) as readonly ExcalidrawElement[]; + // TODO: remove formatting from elements if isPlainPaste + this.addElementsFromPasteOrLibrary({ + elements, + files: data.files || null, + position: isMobileOrTablet() ? "center" : "cursor", + retainSeed: isPlainPaste, + }); + return; + } + + // ------------------- Only textual stuff remaining ------------------- + if (!data.text) { + return; + } + + // ------------------- Successful Mermaid ------------------- + if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) { + const api = await import("@excalidraw/mermaid-to-excalidraw"); + try { + const { elements: skeletonElements, files } = + await api.parseMermaidToExcalidraw(data.text); + + const elements = convertToExcalidrawElements(skeletonElements, { + regenerateIds: true, + }); + + this.addElementsFromPasteOrLibrary({ + elements, + files, + position: isMobileOrTablet() ? "center" : "cursor", + }); + + return; + } catch (err: any) { + console.warn( + `parsing pasted text as mermaid definition failed: ${err.message}`, + ); + } + } + + // ------------------- Pure embeddable URLs ------------------- + const nonEmptyLines = normalizeEOL(data.text) + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + const embbeddableUrls = nonEmptyLines + .map((str) => maybeParseEmbedSrc(str)) + .filter( + (string) => + embeddableURLValidator(string, this.props.validateEmbeddable) && + (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || + getEmbedLink(string)?.type === "video"), + ); + + if ( + !isPlainPaste && + embbeddableUrls.length > 0 && + embbeddableUrls.length === nonEmptyLines.length + ) { + const embeddables: NonDeleted[] = []; + for (const url of embbeddableUrls) { + const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = + embeddables[embeddables.length - 1]; + const embeddable = this.insertEmbeddableElement({ + sceneX: prevEmbeddable + ? prevEmbeddable.x + prevEmbeddable.width + 20 + : sceneX, + sceneY, + link: normalizeLink(url), + }); + if (embeddable) { + embeddables.push(embeddable); + } + } + if (embeddables.length) { + this.store.scheduleCapture(); + this.setState({ + selectedElementIds: Object.fromEntries( + embeddables.map((embeddable) => [embeddable.id, true]), + ), + }); + } + return; + } + + // ------------------- Text ------------------- + this.addTextFromPaste(data.text, isPlainPaste); + } + public pasteFromClipboard = withBatchedUpdates( async (event: ClipboardEvent) => { const isPlainPaste = !!IS_PLAIN_PASTE; @@ -3088,47 +3338,14 @@ class App extends React.Component { return; } - const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - { - clientX: this.lastViewportPosition.x, - clientY: this.lastViewportPosition.y, - }, - this.state, - ); - // must be called in the same frame (thus before any awaits) as the paste // event else some browsers (FF...) will clear the clipboardData // (something something security) - let file = event?.clipboardData?.files[0]; - const data = await parseClipboard(event, isPlainPaste); - if (!file && !isPlainPaste) { - if (data.mixedContent) { - return this.addElementsFromMixedContentPaste(data.mixedContent, { - isPlainPaste, - sceneX, - sceneY, - }); - } else if (data.text) { - const string = data.text.trim(); - if (string.startsWith("")) { - // ignore SVG validation/normalization which will be done during image - // initialization - file = SVGStringToFile(string); - } - } - } + const dataTransferList = await parseDataTransferEvent(event); - // prefer spreadsheet data over image file (MS Office/Libre Office) - if (isSupportedImageFile(file) && !data.spreadsheet) { - if (!this.isToolSupported("image")) { - this.setState({ errorMessage: t("errors.imageToolNotSupported") }); - return; - } + const filesList = dataTransferList.getFiles(); - this.createImageElement({ sceneX, sceneY, imageFile: file }); - - return; - } + const data = await parseClipboard(dataTransferList, isPlainPaste); if (this.props.onPaste) { try { @@ -3140,106 +3357,12 @@ class App extends React.Component { } } - if (data.errorMessage) { - this.setState({ errorMessage: data.errorMessage }); - } else if (data.spreadsheet && !isPlainPaste) { - this.setState({ - pasteDialog: { - data: data.spreadsheet, - shown: true, - }, - }); - } else if (data.elements) { - const elements = ( - data.programmaticAPI - ? convertToExcalidrawElements( - data.elements as ExcalidrawElementSkeleton[], - ) - : data.elements - ) as readonly ExcalidrawElement[]; - // TODO remove formatting from elements if isPlainPaste - this.addElementsFromPasteOrLibrary({ - elements, - files: data.files || null, - position: "cursor", - retainSeed: isPlainPaste, - }); - } else if (data.text) { - if (data.text && isMaybeMermaidDefinition(data.text)) { - const api = await import("@excalidraw/mermaid-to-excalidraw"); + await this.insertClipboardContent(data, filesList, isPlainPaste); - try { - const { elements: skeletonElements, files } = - await api.parseMermaidToExcalidraw(data.text); - - const elements = convertToExcalidrawElements(skeletonElements, { - regenerateIds: true, - }); - - this.addElementsFromPasteOrLibrary({ - elements, - files, - position: "cursor", - }); - - return; - } catch (err: any) { - console.warn( - `parsing pasted text as mermaid definition failed: ${err.message}`, - ); - } - } - - const nonEmptyLines = normalizeEOL(data.text) - .split(/\n+/) - .map((s) => s.trim()) - .filter(Boolean); - - const embbeddableUrls = nonEmptyLines - .map((str) => maybeParseEmbedSrc(str)) - .filter((string) => { - return ( - embeddableURLValidator(string, this.props.validateEmbeddable) && - (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || - getEmbedLink(string)?.type === "video") - ); - }); - - if ( - !IS_PLAIN_PASTE && - embbeddableUrls.length > 0 && - // if there were non-embeddable text (lines) mixed in with embeddable - // urls, ignore and paste as text - embbeddableUrls.length === nonEmptyLines.length - ) { - const embeddables: NonDeleted[] = []; - for (const url of embbeddableUrls) { - const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = - embeddables[embeddables.length - 1]; - const embeddable = this.insertEmbeddableElement({ - sceneX: prevEmbeddable - ? prevEmbeddable.x + prevEmbeddable.width + 20 - : sceneX, - sceneY, - link: normalizeLink(url), - }); - if (embeddable) { - embeddables.push(embeddable); - } - } - if (embeddables.length) { - this.store.scheduleCapture(); - this.setState({ - selectedElementIds: Object.fromEntries( - embeddables.map((embeddable) => [embeddable.id, true]), - ), - }); - } - return; - } - this.addTextFromPaste(data.text, isPlainPaste); - } - this.setActiveTool({ type: "selection" }); + this.setActiveTool( + { type: this.state.preferredSelectionTool.type }, + true, + ); event?.preventDefault(); }, ); @@ -3251,7 +3374,9 @@ class App extends React.Component { retainSeed?: boolean; fitToContent?: boolean; }) => { - const elements = restoreElements(opts.elements, null, undefined); + const elements = restoreElements(opts.elements, null, { + deleteInvisibleElements: true, + }); const [minX, minY, maxX, maxY] = getCommonBounds(elements); const elementsCenterX = distance(minX, maxX) / 2; @@ -3383,7 +3508,7 @@ class App extends React.Component { } }, ); - this.setActiveTool({ type: "selection" }); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true); if (opts.fitToContent) { this.scrollToContent(duplicatedElements, { @@ -3426,45 +3551,11 @@ class App extends React.Component { } }), ); - let y = sceneY; - let firstImageYOffsetDone = false; - const nextSelectedIds: Record = {}; - for (const response of responses) { - if (response.file) { - const initializedImageElement = await this.createImageElement({ - sceneX, - sceneY: y, - imageFile: response.file, - }); - - if (initializedImageElement) { - // vertically center first image in the batch - if (!firstImageYOffsetDone) { - firstImageYOffsetDone = true; - y -= initializedImageElement.height / 2; - } - // hack to reset the `y` coord because we vertically center during - // insertImageElement - this.scene.mutateElement( - initializedImageElement, - { y }, - { informMutation: false, isDragging: false }, - ); - - y = initializedImageElement.y + initializedImageElement.height + 25; - - nextSelectedIds[initializedImageElement.id] = true; - } - } - } - - this.setState({ - selectedElementIds: makeNextSelectedElementIds( - nextSelectedIds, - this.state, - ), - }); + const imageFiles = responses + .filter((response): response is { file: File } => !!response.file) + .map((response) => response.file); + await this.insertImages(imageFiles, sceneX, sceneY); const error = responses.find((response) => !!response.errorMessage); if (error && error.errorMessage) { this.setState({ errorMessage: error.errorMessage }); @@ -3629,7 +3720,7 @@ class App extends React.Component { ...updateActiveTool( this.state, prevState.activeTool.locked - ? { type: "selection" } + ? { type: this.state.preferredSelectionTool.type } : prevState.activeTool, ), locked: !prevState.activeTool.locked, @@ -4098,7 +4189,12 @@ class App extends React.Component { } if (appState) { - this.setState(appState); + this.setState({ + ...appState, + // keep existing stylesPanelMode as it needs to be preserved + // or set at startup + stylesPanelMode: this.state.stylesPanelMode, + } as Pick | null); } if (elements) { @@ -4111,6 +4207,27 @@ class App extends React.Component { }, ); + public applyDeltas = ( + deltas: StoreDelta[], + options?: ApplyToOptions, + ): [SceneElementsMap, AppState, boolean] => { + // squash all deltas together, starting with a fresh new delta instance + const aggregatedDelta = StoreDelta.squash(...deltas); + + // create new instance of elements map & appState, so we don't accidentaly mutate existing ones + const nextAppState = { ...this.state }; + const nextElements = new Map( + this.scene.getElementsMapIncludingDeleted(), + ) as SceneElementsMap; + + return StoreDelta.applyTo( + aggregatedDelta, + nextElements, + nextAppState, + options, + ); + }; + public mutateElement = >( element: TElement, updates: ElementUpdate, @@ -4648,7 +4765,7 @@ class App extends React.Component { !this.state.selectionElement && !this.state.selectedElementsAreBeingDragged ) { - const shape = findShapeByKey(event.key); + const shape = findShapeByKey(event.key, this); if (shape) { if (this.state.activeTool.type !== shape) { trackEvent( @@ -4741,7 +4858,7 @@ class App extends React.Component { if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (this.state.activeTool.type === "laser") { - this.setActiveTool({ type: "selection" }); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }); } else { this.setActiveTool({ type: "laser" }); } @@ -4907,7 +5024,7 @@ class App extends React.Component { this.setState({ suggestedBindings: [] }); } if (nextActiveTool.type === "image") { - this.onImageAction(); + this.onImageToolbarButtonClick(); } this.setState((prevState) => { @@ -5111,17 +5228,8 @@ class App extends React.Component { }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { const isDeleted = !nextOriginalText.trim(); + updateElement(nextOriginalText, isDeleted); - if (isDeleted && !isExistingElement) { - // let's just remove the element from the scene, as it's an empty just created text element - this.scene.replaceAllElements( - this.scene - .getElementsIncludingDeleted() - .filter((x) => x.id !== element.id), - ); - } else { - updateElement(nextOriginalText, isDeleted); - } // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { @@ -5145,15 +5253,16 @@ class App extends React.Component { })); }); } + if (isDeleted) { fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [ element, ]); } - // we need to record either way, whether the text element was added or removed - // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies - this.store.scheduleCapture(); + if (!isDeleted || isExistingElement) { + this.store.scheduleCapture(); + } flushSync(() => { this.setState({ @@ -5338,7 +5447,7 @@ class App extends React.Component { if ( considerBoundingBox && this.state.selectedElementIds[element.id] && - shouldShowBoundingBox([element], this.state) + hasBoundingBox([element], this.state) ) { // if hitting the bounding box, return early // but if not, we should check for other cases as well (e.g. frame name) @@ -5600,7 +5709,7 @@ class App extends React.Component { return; } // we should only be able to double click when mode is selection - if (this.state.activeTool.type !== "selection") { + if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) { return; } @@ -5892,8 +6001,9 @@ class App extends React.Component { const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() - .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame, elementsMap), + .filter( + (frame): frame is ExcalidrawFrameLikeElement => + !frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; @@ -6211,6 +6321,7 @@ class App extends React.Component { if ( hasDeselectedButton || (this.state.activeTool.type !== "selection" && + this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "text" && this.state.activeTool.type !== "eraser") ) { @@ -6239,7 +6350,13 @@ class App extends React.Component { (!this.state.selectedLinearElement || this.state.selectedLinearElement.hoverPointIndex === -1) && this.state.openDialog?.name !== "elementLinkSelector" && - !(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) + !(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) && + // HACK: Disable transform handles for linear elements on mobile until a + // better way of showing them is found + !( + isLinearElement(selectedElements[0]) && + (isMobileOrTablet() || selectedElements[0].points.length === 2) + ) ) { const elementWithTransformHandleType = getElementWithTransformHandleType( @@ -6373,7 +6490,12 @@ class App extends React.Component { !isElbowArrow(hitElement) || !(hitElement.startBinding || hitElement.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + selectedElements.length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } if (this.state.activeEmbeddable?.state === "hover") { this.setState({ activeEmbeddable: null }); } @@ -6490,7 +6612,12 @@ class App extends React.Component { !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + Object.keys(this.state.selectedElementIds).length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { @@ -6499,7 +6626,12 @@ class App extends React.Component { !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + Object.keys(this.state.selectedElementIds).length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } } @@ -6576,6 +6708,10 @@ class App extends React.Component { this.setAppState({ snapLines: [] }); } + if (this.state.openPopup) { + this.setState({ openPopup: null }); + } + this.updateGestureOnPointerDown(event); // if dragging element is freedraw and another pointerdown event occurs @@ -6761,11 +6897,117 @@ class App extends React.Component { } if (this.state.activeTool.type === "lasso") { - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); + const hitSelectedElement = + pointerDownState.hit.element && + this.isASelectedElement(pointerDownState.hit.element); + + if ( + !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + !pointerDownState.resize.handleType && + !hitSelectedElement + ) { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + + // block dragging after lasso selection on PCs until the next pointer down + // (on mobile or tablet, we want to allow user to drag immediately) + pointerDownState.drag.blockDragging = !isMobileOrTablet(); + } + + // only for mobile or tablet, if we hit an element, select it immediately like normal selection + if ( + isMobileOrTablet() && + pointerDownState.hit.element && + !hitSelectedElement + ) { + this.setState((prevState) => { + const nextSelectedElementIds: { [id: string]: true } = { + ...prevState.selectedElementIds, + [pointerDownState.hit.element!.id]: true, + }; + + const previouslySelectedElements: ExcalidrawElement[] = []; + + Object.keys(prevState.selectedElementIds).forEach((id) => { + const element = this.scene.getElement(id); + element && previouslySelectedElements.push(element); + }); + + const hitElement = pointerDownState.hit.element!; + + // if hitElement is frame-like, deselect all of its elements + // if they are selected + if (isFrameLikeElement(hitElement)) { + getFrameChildren(previouslySelectedElements, hitElement.id).forEach( + (element) => { + delete nextSelectedElementIds[element.id]; + }, + ); + } else if (hitElement.frameId) { + // if hitElement is in a frame and its frame has been selected + // disable selection for the given element + if (nextSelectedElementIds[hitElement.frameId]) { + delete nextSelectedElementIds[hitElement.id]; + } + } else { + // hitElement is neither a frame nor an element in a frame + // but since hitElement could be in a group with some frames + // this means selecting hitElement will have the frames selected as well + // because we want to keep the invariant: + // - frames and their elements are not selected at the same time + // we deselect elements in those frames that were previously selected + + const groupIds = hitElement.groupIds; + const framesInGroups = new Set( + groupIds + .flatMap((gid) => + getElementsInGroup(this.scene.getNonDeletedElements(), gid), + ) + .filter((element) => isFrameLikeElement(element)) + .map((frame) => frame.id), + ); + + if (framesInGroups.size > 0) { + previouslySelectedElements.forEach((element) => { + if (element.frameId && framesInGroups.has(element.frameId)) { + // deselect element and groups containing the element + delete nextSelectedElementIds[element.id]; + element.groupIds + .flatMap((gid) => + getElementsInGroup( + this.scene.getNonDeletedElements(), + gid, + ), + ) + .forEach((element) => { + delete nextSelectedElementIds[element.id]; + }); + } + }); + } + } + + return { + ...selectGroupsForSelectedElements( + { + editingGroupId: prevState.editingGroupId, + selectedElementIds: nextSelectedElementIds, + }, + this.scene.getNonDeletedElements(), + prevState, + this, + ), + showHyperlinkPopup: + hitElement.link || isEmbeddableElement(hitElement) + ? "info" + : false, + }; + }); + pointerDownState.hit.wasAddedToSelection = true; + } } else if (this.state.activeTool.type === "text") { this.handleTextOnPointerDown(event, pointerDownState); } else if ( @@ -7145,6 +7387,7 @@ class App extends React.Component { hasOccurred: false, offset: null, origin: { ...origin }, + blockDragging: false, }, eventListeners: { onMove: null, @@ -7220,7 +7463,10 @@ class App extends React.Component { event: React.PointerEvent, pointerDownState: PointerDownState, ): boolean => { - if (this.state.activeTool.type === "selection") { + if ( + this.state.activeTool.type === "selection" || + this.state.activeTool.type === "lasso" + ) { const elements = this.scene.getNonDeletedElements(); const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); @@ -7229,6 +7475,10 @@ class App extends React.Component { selectedElements.length === 1 && !this.state.selectedLinearElement?.isEditing && !isElbowArrow(selectedElements[0]) && + !( + isLinearElement(selectedElements[0]) && + (isMobileOrTablet() || selectedElements[0].points.length === 2) + ) && !( this.state.selectedLinearElement && this.state.selectedLinearElement.hoverPointIndex !== -1 @@ -7427,7 +7677,18 @@ class App extends React.Component { // on CMD/CTRL, drill down to hit element regardless of groups etc. if (event[KEYS.CTRL_OR_CMD]) { if (event.altKey) { - // ctrl + alt means we're lasso selecting + // ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool + + // Close any open dialogs that might interfere with lasso selection + if (this.state.openDialog?.name === "elementLinkSelector") { + this.setOpenDialog(null); + } + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + this.setActiveTool({ type: "lasso", fromSelection: true }); return false; } if (!this.state.selectedElementIds[hitElement.id]) { @@ -7648,7 +7909,9 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); if (!this.state.activeTool.locked) { this.setState({ - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), }); } }; @@ -7815,16 +8078,14 @@ class App extends React.Component { return element; }; - private createImageElement = async ({ + private newImagePlaceholder = ({ sceneX, sceneY, addToFrameUnderCursor = true, - imageFile, }: { sceneX: number; sceneY: number; addToFrameUnderCursor?: boolean; - imageFile: File; }) => { const [gridX, gridY] = getGridPoint( sceneX, @@ -7843,7 +8104,7 @@ class App extends React.Component { const placeholderSize = 100 / this.state.zoom.value; - const placeholderImageElement = newImageElement({ + return newImageElement({ type: "image", strokeColor: this.state.currentItemStrokeColor, backgroundColor: this.state.currentItemBackgroundColor, @@ -7860,13 +8121,6 @@ class App extends React.Component { width: placeholderSize, height: placeholderSize, }); - - const initializedImageElement = await this.insertImageElement( - placeholderImageElement, - imageFile, - ); - - return initializedImageElement; }; private handleLinearElementOnPointerDown = ( @@ -8432,15 +8686,18 @@ class App extends React.Component { event.shiftKey && this.state.selectedLinearElement.elementId === pointerDownState.hit.element?.id; + if ( (hasHitASelectedElement || pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && !isSelectingPointsInLineEditor && - this.state.activeTool.type !== "lasso" + !pointerDownState.drag.blockDragging ) { const selectedElements = this.scene.getSelectedElements(this.state); - - if (selectedElements.every((element) => element.locked)) { + if ( + selectedElements.length > 0 && + selectedElements.every((element) => element.locked) + ) { return; } @@ -8461,6 +8718,29 @@ class App extends React.Component { // if elements should be deselected on pointerup pointerDownState.drag.hasOccurred = true; + // prevent immediate dragging during lasso selection to avoid element displacement + // only allow dragging if we're not in the middle of lasso selection + // (on mobile, allow dragging if we hit an element) + if ( + this.state.activeTool.type === "lasso" && + this.lassoTrail.hasCurrentTrail && + !(isMobileOrTablet() && pointerDownState.hit.element) && + !this.state.activeTool.fromSelection + ) { + return; + } + + // Clear lasso trail when starting to drag selected elements with lasso tool + // Only clear if we're actually dragging (not during lasso selection) + if ( + this.state.activeTool.type === "lasso" && + selectedElements.length > 0 && + pointerDownState.drag.hasOccurred && + !this.state.activeTool.fromSelection + ) { + this.lassoTrail.endPath(); + } + // prevent dragging even if we're no longer holding cmd/ctrl otherwise // it would have weird results (stuff jumping all over the screen) // Checking for editingTextElement to avoid jump while editing on mobile #6503 @@ -9055,6 +9335,7 @@ class App extends React.Component { ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { this.removePointer(childEvent); + pointerDownState.drag.blockDragging = false; if (pointerDownState.eventListeners.onMove) { pointerDownState.eventListeners.onMove.flush(); } @@ -9343,7 +9624,7 @@ class App extends React.Component { this.setState((prevState) => ({ newElement: null, activeTool: updateActiveTool(this.state, { - type: "selection", + type: this.state.preferredSelectionTool.type, }), selectedElementIds: makeNextSelectedElementIds( { @@ -9959,7 +10240,9 @@ class App extends React.Component { this.setState({ newElement: null, suggestedBindings: [], - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), }); } else { this.setState({ @@ -10159,64 +10442,7 @@ class App extends React.Component { ); }; - /** - * inserts image into elements array and rerenders - */ - private insertImageElement = async ( - placeholderImageElement: ExcalidrawImageElement, - imageFile: File, - ) => { - // we should be handling all cases upstream, but in case we forget to handle - // a future case, let's throw here - if (!this.isToolSupported("image")) { - this.setState({ errorMessage: t("errors.imageToolNotSupported") }); - return; - } - - this.scene.insertElement(placeholderImageElement); - - try { - const initializedImageElement = await this.initializeImage( - placeholderImageElement, - imageFile, - ); - - const nextElements = this.scene - .getElementsIncludingDeleted() - .map((element) => { - if (element.id === initializedImageElement.id) { - return initializedImageElement; - } - - return element; - }); - - this.updateScene({ - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - elements: nextElements, - appState: { - selectedElementIds: makeNextSelectedElementIds( - { [initializedImageElement.id]: true }, - this.state, - ), - }, - }); - - return initializedImageElement; - } catch (error: any) { - this.store.scheduleAction(CaptureUpdateAction.NEVER); - this.scene.mutateElement(placeholderImageElement, { - isDeleted: true, - }); - this.actionManager.executeAction(actionFinalize); - this.setState({ - errorMessage: error.message || t("errors.imageInsertError"), - }); - return null; - } - }; - - private onImageAction = async () => { + private onImageToolbarButtonClick = async () => { try { const clientX = this.state.width / 2 + this.state.offsetLeft; const clientY = this.state.height / 2 + this.state.offsetTop; @@ -10226,24 +10452,15 @@ class App extends React.Component { this.state, ); - const imageFile = await fileOpen({ + const imageFiles = await fileOpen({ description: "Image", extensions: Object.keys( IMAGE_MIME_TYPES, ) as (keyof typeof IMAGE_MIME_TYPES)[], + multiple: true, }); - await this.createImageElement({ - sceneX: x, - sceneY: y, - addToFrameUnderCursor: false, - imageFile, - }); - - // avoid being batched (just in case) - this.setState({}, () => { - this.actionManager.executeAction(actionFinalize); - }); + this.insertImages(imageFiles, x, y); } catch (error: any) { if (error.name !== "AbortError") { console.error(error); @@ -10253,7 +10470,9 @@ class App extends React.Component { this.setState( { newElement: null, - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), }, () => { this.actionManager.executeAction(actionFinalize); @@ -10305,7 +10524,7 @@ class App extends React.Component { if (erroredFiles.size) { this.store.scheduleAction(CaptureUpdateAction.NEVER); this.scene.replaceAllElements( - elements.map((element) => { + this.scene.getElementsIncludingDeleted().map((element) => { if ( isInitializedImageElement(element) && erroredFiles.has(element.fileId) @@ -10438,84 +10657,174 @@ class App extends React.Component { } }; + private insertImages = async ( + imageFiles: File[], + sceneX: number, + sceneY: number, + ) => { + const gridPadding = 50 / this.state.zoom.value; + // Create, position, and insert placeholders + const placeholders = positionElementsOnGrid( + imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })), + sceneX, + sceneY, + gridPadding, + ); + placeholders.forEach((el) => this.scene.insertElement(el)); + + // Create, position, insert and select initialized (replacing placeholders) + const initialized = await Promise.all( + placeholders.map(async (placeholder, i) => { + try { + return await this.initializeImage( + placeholder, + await normalizeFile(imageFiles[i]), + ); + } catch (error: any) { + this.setState({ + errorMessage: error.message || t("errors.imageInsertError"), + }); + return newElementWith(placeholder, { isDeleted: true }); + } + }), + ); + const initializedMap = arrayToMap(initialized); + + const positioned = positionElementsOnGrid( + initialized.filter((el) => !el.isDeleted), + sceneX, + sceneY, + gridPadding, + ); + const positionedMap = arrayToMap(positioned); + + const nextElements = this.scene + .getElementsIncludingDeleted() + .map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el); + + this.updateScene({ + appState: { + selectedElementIds: makeNextSelectedElementIds( + Object.fromEntries(positioned.map((el) => [el.id, true])), + this.state, + ), + }, + elements: nextElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + this.setState({}, () => { + // actionFinalize after all state values have been updated + this.actionManager.executeAction(actionFinalize); + }); + }; + private handleAppOnDrop = async (event: React.DragEvent) => { - // must be retrieved first, in the same frame - const { file, fileHandle } = await getFileFromEvent(event); const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( event, this.state, ); + const dataTransferList = await parseDataTransferEvent(event); - try { - // if image tool not supported, don't show an error here and let it fall - // through so we still support importing scene data from images. If no - // scene data encoded, we'll show an error then - if (isSupportedImageFile(file) && this.isToolSupported("image")) { - // first attempt to decode scene from the image if it's embedded - // --------------------------------------------------------------------- + // must be retrieved first, in the same frame + const fileItems = dataTransferList.getFiles(); - if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) { - try { - const scene = await loadFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); - this.syncActionResult({ - ...scene, - appState: { - ...(scene.appState || this.state), - isLoading: false, - }, - replaceFiles: true, - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - }); - return; - } catch (error: any) { - // Don't throw for image scene daa - if (error.name !== "EncodingError") { - throw new Error(t("alerts.couldNotLoadInvalidFile")); - } + if (fileItems.length === 1) { + const { file, fileHandle } = fileItems[0]; + + if ( + file && + (file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg) + ) { + try { + const scene = await loadFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + fileHandle, + ); + this.syncActionResult({ + ...scene, + appState: { + ...(scene.appState || this.state), + isLoading: false, + }, + replaceFiles: true, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + return; + } catch (error: any) { + if (error.name !== "EncodingError") { + throw new Error(t("alerts.couldNotLoadInvalidFile")); } + // if EncodingError, fall through to insert as regular image } - - // if no scene is embedded or we fail for whatever reason, fall back - // to importing as regular image - // --------------------------------------------------------------------- - this.createImageElement({ sceneX, sceneY, imageFile: file }); - - return; } - } catch (error: any) { - return this.setState({ - isLoading: false, - errorMessage: error.message, - }); } - const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib); - if (libraryJSON && typeof libraryJSON === "string") { + const imageFiles = fileItems + .map((data) => data.file) + .filter((file) => isSupportedImageFile(file)); + + if (imageFiles.length > 0 && this.isToolSupported("image")) { + return this.insertImages(imageFiles, sceneX, sceneY); + } + const excalidrawLibrary_ids = dataTransferList.getData( + MIME_TYPES.excalidrawlibIds, + ); + const excalidrawLibrary_data = dataTransferList.getData( + MIME_TYPES.excalidrawlib, + ); + if (excalidrawLibrary_ids || excalidrawLibrary_data) { try { - const libraryItems = parseLibraryJSON(libraryJSON); - this.addElementsFromPasteOrLibrary({ - elements: distributeLibraryItemsOnSquareGrid(libraryItems), - position: event, - files: null, - }); + let libraryItems: LibraryItems | null = null; + if (excalidrawLibrary_ids) { + const { itemIds } = JSON.parse( + excalidrawLibrary_ids, + ) as ExcalidrawLibraryIds; + const allLibraryItems = await this.library.getLatestLibrary(); + libraryItems = allLibraryItems.filter((item) => + itemIds.includes(item.id), + ); + // legacy library dataTransfer format + } else if (excalidrawLibrary_data) { + libraryItems = parseLibraryJSON(excalidrawLibrary_data); + } + if (libraryItems?.length) { + libraryItems = libraryItems.map((item) => ({ + ...item, + // #6465 + elements: duplicateElements({ + type: "everything", + elements: item.elements, + randomizeSeed: true, + }).duplicatedElements, + })); + + this.addElementsFromPasteOrLibrary({ + elements: distributeLibraryItemsOnSquareGrid(libraryItems), + position: event, + files: null, + }); + } } catch (error: any) { this.setState({ errorMessage: error.message }); } return; } - if (file) { - // Attempt to parse an excalidraw/excalidrawlib file - await this.loadFileToCanvas(file, fileHandle); + if (fileItems.length > 0) { + const { file, fileHandle } = fileItems[0]; + if (file) { + // Attempt to parse an excalidraw/excalidrawlib file + await this.loadFileToCanvas(file, fileHandle); + } } - if (event.dataTransfer?.types?.includes("text/plain")) { - const text = event.dataTransfer?.getData("text"); + const textItem = dataTransferList.findByType(MIME_TYPES.text); + + if (textItem) { + const text = textItem.value; if ( text && embeddableURLValidator(text, this.props.validateEmbeddable) && @@ -10626,7 +10935,7 @@ class App extends React.Component { event.nativeEvent.pointerType === "pen" && // always allow if user uses a pen secondary button event.button !== POINTER_BUTTON.SECONDARY)) && - this.state.activeTool.type !== "selection" + this.state.activeTool.type !== this.state.preferredSelectionTool.type ) { return; } @@ -11080,6 +11389,17 @@ class App extends React.Component { return [actionCopy, ...options]; } + const zIndexActions: ContextMenuItems = + this.state.stylesPanelMode === "full" + ? [ + CONTEXT_MENU_SEPARATOR, + actionSendBackward, + actionBringForward, + actionSendToBack, + actionBringToFront, + ] + : []; + return [ CONTEXT_MENU_SEPARATOR, actionCut, @@ -11105,11 +11425,7 @@ class App extends React.Component { actionUngroup, CONTEXT_MENU_SEPARATOR, actionAddToLibrary, - CONTEXT_MENU_SEPARATOR, - actionSendBackward, - actionBringForward, - actionSendToBack, - actionBringToFront, + ...zIndexActions, CONTEXT_MENU_SEPARATOR, actionFlipHorizontal, actionFlipVertical, diff --git a/packages/excalidraw/components/ColorPicker/ColorInput.tsx b/packages/excalidraw/components/ColorPicker/ColorInput.tsx index e5e6f3a771..557f9c1c00 100644 --- a/packages/excalidraw/components/ColorPicker/ColorInput.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorInput.tsx @@ -1,8 +1,9 @@ import clsx from "clsx"; 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 { t } from "../../i18n"; import { useDevice } from "../App"; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss index 7a78395d6f..658a75dad7 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.scss +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -7,6 +7,12 @@ } } + .color-picker__title { + padding: 0 0.5rem; + font-size: 0.875rem; + text-align: left; + } + .color-picker__heading { padding: 0 0.5rem; font-size: 0.75rem; @@ -22,6 +28,12 @@ @include isMobile { max-width: 11rem; } + + &.color-picker-container--no-top-picks { + display: flex; + justify-content: center; + grid-template-columns: unset; + } } .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 { .color-picker__button-outline { position: absolute; @@ -141,6 +163,15 @@ width: 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 { diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 270d61f4cd..238960fa0b 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -1,11 +1,12 @@ import * as Popover from "@radix-ui/react-popover"; import clsx from "clsx"; -import { useRef } from "react"; +import { useRef, useEffect } from "react"; import { COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_PALETTE, isTransparent, + isWritableElement, } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; @@ -18,7 +19,12 @@ import { useExcalidrawContainer } from "../App"; import { ButtonSeparator } from "../ButtonSeparator"; import { activeEyeDropperAtom } from "../EyeDropper"; 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 { Picker } from "./Picker"; @@ -67,6 +73,7 @@ interface ColorPickerProps { palette?: ColorPaletteCustom | null; topPicks?: ColorTuple; updateData: (formData?: any) => void; + compactMode?: boolean; } const ColorPickerPopupContent = ({ @@ -77,6 +84,8 @@ const ColorPickerPopupContent = ({ elements, palette = COLOR_PALETTE, updateData, + getOpenPopup, + appState, }: Pick< ColorPickerProps, | "type" @@ -86,7 +95,10 @@ const ColorPickerPopupContent = ({ | "elements" | "palette" | "updateData" ->) => { + | "appState" +> & { + getOpenPopup: () => AppState["openPopup"]; +}) => { const { container } = useExcalidrawContainer(); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); @@ -117,9 +129,13 @@ const ColorPickerPopupContent = ({ { // refocus due to eye dropper - focusPickerContent(); + if (!isWritableElement(event.target)) { + focusPickerContent(); + } event.preventDefault(); }} onPointerDownOutside={(event) => { @@ -131,8 +147,23 @@ const ColorPickerPopupContent = ({ } }} 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); + + // 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 ? ( @@ -141,7 +172,17 @@ const ColorPickerPopupContent = ({ palette={palette} color={color} onChange={(changedColor) => { + // Save caret position before color change if editing text + const savedSelection = appState.editingTextElement + ? saveCaretPosition() + : null; + onChange(changedColor); + + // Restore caret position after color change if editing text + if (appState.editingTextElement && savedSelection) { + restoreCaretPosition(savedSelection); + } }} onEyeDropperToggle={(force) => { setEyeDropperState((state) => { @@ -168,12 +209,18 @@ const ColorPickerPopupContent = ({ if (eyeDropperState) { setEyeDropperState(null); } else { + // close explicitly on Escape updateData({ openPopup: null }); } }} type={type} elements={elements} updateData={updateData} + showTitle={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } + showHotKey={appState.stylesPanelMode !== "mobile"} > {colorInputJSX} @@ -188,11 +235,32 @@ const ColorPickerTrigger = ({ label, color, type, + stylesPanelMode, + mode = "background", + onToggle, + editingTextElement, }: { color: string | null; label: string; 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 (
{!color && slashIcon}
+ {(stylesPanelMode === "compact" || stylesPanelMode === "mobile") && + color && + mode === "stroke" && ( +
+ + {strokeIcon} + +
+ )}
); }; @@ -225,24 +314,62 @@ export const ColorPicker = ({ updateData, appState, }: ColorPickerProps) => { + const openRef = useRef(appState.openPopup); + useEffect(() => { + openRef.current = appState.openPopup; + }, [appState.openPopup]); + const compactMode = + type !== "canvasBackground" && + (appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile"); + return (
-
- - +
+ {!compactMode && ( + + )} + {!compactMode && } { - updateData({ openPopup: open ? type : null }); + if (open) { + updateData({ openPopup: type }); + } }} > {/* serves as an active color indicator as well */} - + { + // 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 */} {appState.openPopup === type && ( openRef.current} + appState={appState} /> )} diff --git a/packages/excalidraw/components/ColorPicker/Picker.tsx b/packages/excalidraw/components/ColorPicker/Picker.tsx index f784912f4c..9c48c58075 100644 --- a/packages/excalidraw/components/ColorPicker/Picker.tsx +++ b/packages/excalidraw/components/ColorPicker/Picker.tsx @@ -37,8 +37,10 @@ interface PickerProps { palette: ColorPaletteCustom; updateData: (formData?: any) => void; children?: React.ReactNode; + showTitle?: boolean; onEyeDropperToggle: (force?: boolean) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; + showHotKey?: boolean; } export const Picker = React.forwardRef( @@ -51,11 +53,21 @@ export const Picker = React.forwardRef( palette, updateData, children, + showTitle, onEyeDropperToggle, onEscape, + showHotKey = true, }: PickerProps, ref, ) => { + const title = showTitle + ? type === "elementStroke" + ? t("labels.stroke") + : type === "elementBackground" + ? t("labels.background") + : null + : null; + const [customColors] = React.useState(() => { if (type === "canvasBackground") { return []; @@ -154,6 +166,8 @@ export const Picker = React.forwardRef( // to allow focusing by clicking but not by tabbing tabIndex={-1} > + {title &&
{title}
} + {!!customColors.length && (
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef( palette={palette} onChange={onChange} activeShade={activeShade} + showHotKey={showHotKey} />
{t("colorPicker.shades")} - +
{children}
diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx index 4fd6815e44..13928f0239 100644 --- a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx @@ -20,6 +20,7 @@ interface PickerColorListProps { color: string | null; onChange: (color: string) => void; activeShade: number; + showHotKey?: boolean; } const PickerColorList = ({ @@ -27,6 +28,7 @@ const PickerColorList = ({ color, onChange, activeShade, + showHotKey = true, }: PickerColorListProps) => { const colorObj = getColorNameAndShadeFromColor({ color, @@ -82,7 +84,7 @@ const PickerColorList = ({ key={key} >
- + {showHotKey && } ); })} diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx index db33402b0c..2c17c57ede 100644 --- a/packages/excalidraw/components/ColorPicker/ShadeList.tsx +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -16,9 +16,15 @@ interface ShadeListProps { color: string | null; onChange: (color: string) => void; palette: ColorPaletteCustom; + showHotKey?: boolean; } -export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { +export const ShadeList = ({ + color, + onChange, + palette, + showHotKey, +}: ShadeListProps) => { const colorObj = getColorNameAndShadeFromColor({ color: color || "transparent", palette, @@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { }} >
- + {showHotKey && ( + + )} ))}
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index 90db95db69..0a02c23b04 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -100,6 +100,19 @@ $verticalBreakpoint: 861px; border-radius: var(--border-radius-lg); 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 { background-color: var(--color-surface-low); } @@ -130,9 +143,17 @@ $verticalBreakpoint: 861px; } .icon { - width: 16px; - height: 16px; - margin-right: 6px; + width: var(--icon-size, 1rem); + height: var(--icon-size, 1rem); + margin-right: 0.375rem; + + .library-item-icon { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } } } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 3c6f110d27..2ef757d5c5 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -1,18 +1,19 @@ import clsx from "clsx"; import fuzzy from "fuzzy"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useMemo, useState } from "react"; import { DEFAULT_SIDEBAR, EVENT, KEYS, capitalizeString, - getShortcutKey, isWritableElement, } from "@excalidraw/common"; import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch"; +import { getShortcutKey } from "@excalidraw/excalidraw/shortcut"; + import type { MarkRequired } from "@excalidraw/common/utility-types"; import { @@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable"; 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 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 { TranslationKeys } from "../../i18n"; import type { Action } from "../../actions/types"; @@ -80,6 +90,7 @@ export const DEFAULT_CATEGORIES = { editor: "Editor", elements: "Elements", links: "Links", + library: "Library", }; const getCategoryOrder = (category: string) => { @@ -207,6 +218,34 @@ function CommandPaletteInner({ appProps, }); + const [libraryItemsData] = useAtom(libraryItemsAtom); + const libraryCommands: CommandPaletteItem[] = useMemo(() => { + return ( + libraryItemsData.libraryItems + ?.filter( + (libraryItem): libraryItem is MarkRequired => + !!libraryItem.name, + ) + .map((libraryItem) => ({ + label: libraryItem.name, + icon: ( + + ), + category: "Library", + order: getCategoryOrder("Library"), + haystack: deburr(libraryItem.name), + perform: () => { + app.onInsertElements( + distributeLibraryItemsOnSquareGrid([libraryItem]), + ); + }, + })) || [] + ); + }, [app, libraryItemsData.libraryItems]); + useEffect(() => { // these props change often and we don't want them to re-run the effect // which would renew `allCommands`, cascading down and resetting state. @@ -438,7 +477,6 @@ function CommandPaletteInner({ }, perform: () => { setAppState((prevState) => ({ - openMenu: prevState.openMenu === "shape" ? null : "shape", openPopup: "elementStroke", })); }, @@ -458,7 +496,6 @@ function CommandPaletteInner({ }, perform: () => { setAppState((prevState) => ({ - openMenu: prevState.openMenu === "shape" ? null : "shape", openPopup: "elementBackground", })); }, @@ -588,8 +625,9 @@ function CommandPaletteInner({ setAllCommands(allCommands); setLastUsed( - allCommands.find((command) => command.label === lastUsed?.label) ?? - null, + [...allCommands, ...libraryCommands].find( + (command) => command.label === lastUsed?.label, + ) ?? null, ); } }, [ @@ -600,6 +638,7 @@ function CommandPaletteInner({ lastUsed?.label, setLastUsed, setAppState, + libraryCommands, ]); const [commandSearch, setCommandSearch] = useState(""); @@ -796,9 +835,17 @@ function CommandPaletteInner({ return nextCommandsByCategory; }; - let matchingCommands = allCommands - .filter(isCommandAvailable) - .sort((a, b) => a.order - b.order); + let matchingCommands = + commandSearch?.length > 1 + ? [ + ...allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order), + ...libraryCommands, + ] + : allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); const showLastUsed = !commandSearch && lastUsed && isCommandAvailable(lastUsed); @@ -822,14 +869,20 @@ function CommandPaletteInner({ ); matchingCommands = fuzzy .filter(_query, matchingCommands, { - extract: (command) => command.haystack, + extract: (command) => command.haystack ?? "", }) .sort((a, b) => b.score - a.score) .map((item) => item.original); setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCurrentCommand(matchingCommands[0] ?? null); - }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + }, [ + commandSearch, + allCommands, + isCommandAvailable, + lastUsed, + libraryCommands, + ]); return ( setCurrentCommand(command)} showShortcut={!app.device.viewport.isMobile} appState={uiAppState} + size={category === "Library" ? "large" : "small"} /> ))}
@@ -919,6 +973,20 @@ function CommandPaletteInner({ ); } +const LibraryItemIcon = ({ + id, + elements, +}: { + id: LibraryItem["id"] | null; + elements: LibraryItem["elements"] | undefined; +}) => { + const ref = useRef(null); + const { svgCache } = useLibraryCache(); + + useLibraryItemSvg(id, elements, svgCache, ref); + + return
; +}; const CommandItem = ({ command, @@ -928,6 +996,7 @@ const CommandItem = ({ onClick, showShortcut, appState, + size = "small", }: { command: CommandPaletteItem; isSelected: boolean; @@ -936,6 +1005,7 @@ const CommandItem = ({ onClick: (event: React.MouseEvent) => void; showShortcut: boolean; appState: UIAppState; + size?: "small" | "large"; }) => { const noop = () => {}; @@ -944,6 +1014,7 @@ const CommandItem = ({ className={clsx("command-item", { "item-selected": isSelected, "item-disabled": disabled, + "command-item-large": size === "large", })} ref={(ref) => { if (isSelected && !disabled) { @@ -959,6 +1030,8 @@ const CommandItem = ({
{command.icon && (
    ( ); -type LogoSize = "xs" | "small" | "normal" | "large" | "custom"; +type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile"; interface LogoProps { size?: LogoSize; diff --git a/packages/excalidraw/components/FontPicker/FontPicker.scss b/packages/excalidraw/components/FontPicker/FontPicker.scss index 5a572585e1..70859e8091 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.scss +++ b/packages/excalidraw/components/FontPicker/FontPicker.scss @@ -11,5 +11,10 @@ 2rem + 4 * var(--default-button-size) ); // 4 gaps + 4 buttons } + + &--compact { + display: block; + grid-template-columns: none; + } } } diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 118c6fac3c..c52286a173 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,4 +1,5 @@ import * as Popover from "@radix-ui/react-popover"; +import clsx from "clsx"; import React, { useCallback, useMemo } from "react"; import { FONT_FAMILY } from "@excalidraw/common"; @@ -58,6 +59,7 @@ interface FontPickerProps { onHover: (fontFamily: FontFamilyValues) => void; onLeave: () => void; onPopupChange: (open: boolean) => void; + compactMode?: boolean; } export const FontPicker = React.memo( @@ -69,6 +71,7 @@ export const FontPicker = React.memo( onHover, onLeave, onPopupChange, + compactMode = false, }: FontPickerProps) => { const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const onSelectCallback = useCallback( @@ -81,18 +84,30 @@ export const FontPicker = React.memo( ); return ( -
    -
    - - type="button" - options={defaultFonts} - value={selectedFontFamily} - onClick={onSelectCallback} - /> -
    - +
    + {!compactMode && ( +
    + + type="button" + options={defaultFonts} + value={selectedFontFamily} + onClick={onSelectCallback} + /> +
    + )} + {!compactMode && } - + {isOpened && ( { const { container } = useExcalidrawContainer(); - const { fonts } = useApp(); + const app = useApp(); + const { fonts } = app; const { showDeprecatedFonts } = useAppProps(); const [searchTerm, setSearchTerm] = useState(""); @@ -187,6 +188,42 @@ export const FontPickerList = React.memo( 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>( (event) => { const handled = fontPickerKeyHandler({ @@ -194,7 +231,7 @@ export const FontPickerList = React.memo( inputRef, hoveredFont, filteredFonts, - onSelect, + onSelect: wrappedOnSelect, onHover, onClose, }); @@ -204,7 +241,7 @@ export const FontPickerList = React.memo( event.stopPropagation(); } }, - [hoveredFont, filteredFonts, onSelect, onHover, onClose], + [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], ); useEffect(() => { @@ -240,7 +277,7 @@ export const FontPickerList = React.memo( // allow to tab between search and selected font tabIndex={font.value === selectedFontFamily ? 0 : -1} onClick={(e) => { - onSelect(Number(e.currentTarget.value)); + wrappedOnSelect(Number(e.currentTarget.value)); }} onMouseMove={() => { if (hoveredFont?.value !== font.value) { @@ -282,15 +319,32 @@ export const FontPickerList = React.memo( className="properties-content" container={container} 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} onKeyDown={onKeyDown} + preventAutoFocusOnTouch={!!app.state.editingTextElement} > - + {app.state.stylesPanelMode === "full" && ( + + )} { - const isTriggerActive = useMemo( - () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)), - [selectedFontFamily], - ); + const setAppState = useExcalidrawSetAppState(); + + const compactStyle = compactMode + ? { + ...MOBILE_ACTION_BUTTON_BG, + width: "2rem", + height: "2rem", + } + : {}; return ( - {/* Empty div as trigger so it's stretched 100% due to different button sizes */} -
    +
    {}} + active={isOpened} + onClick={() => { + setAppState((appState) => ({ + openPopup: + appState.openPopup === "fontFamily" ? null : appState.openPopup, + })); + }} + style={{ + border: "none", + ...compactStyle, + }} />
    diff --git a/packages/excalidraw/components/HandButton.tsx b/packages/excalidraw/components/HandButton.tsx index 5ebfdf9d3f..db653a8103 100644 --- a/packages/excalidraw/components/HandButton.tsx +++ b/packages/excalidraw/components/HandButton.tsx @@ -18,7 +18,7 @@ type LockIconProps = { export const HandButton = (props: LockIconProps) => { return ( span { 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 { .HintViewer { color: var(--color-gray-60); + kbd { + border-color: var(--color-gray-60); + } } } } diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 39870a34df..3cb62e7597 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -9,11 +9,10 @@ import { isTextElement, } from "@excalidraw/element"; -import { getShortcutKey } from "@excalidraw/common"; - import { isNodeInFlowchart } from "@excalidraw/element"; import { t } from "../i18n"; +import { getShortcutKey } from "../shortcut"; import { isEraserActive } from "../appState"; import { isGridModeEnabled } from "../snapping"; @@ -28,6 +27,11 @@ interface HintViewerProps { app: AppClassProperties; } +const getTaggedShortcutKey = (key: string | string[]) => + Array.isArray(key) + ? `${key.map(getShortcutKey).join(" + ")}` + : `${getShortcutKey(key)}`; + const getHints = ({ appState, isMobile, @@ -42,7 +46,9 @@ const getHints = ({ appState.openSidebar.tab === CANVAS_SEARCH_TAB && appState.searchMatches?.matches.length ) { - return t("hints.dismissSearch"); + return t("hints.dismissSearch", { + shortcut: getTaggedShortcutKey("Escape"), + }); } if (appState.openSidebar && !device.editor.canFitSidebar) { @@ -50,14 +56,21 @@ const getHints = ({ } if (isEraserActive(appState)) { - return t("hints.eraserRevert"); + return t("hints.eraserRevert", { + shortcut: getTaggedShortcutKey("Alt"), + }); } if (activeTool.type === "arrow" || activeTool.type === "line") { if (multiMode) { - return t("hints.linearElementMulti"); + return t("hints.linearElementMulti", { + shortcut_1: getTaggedShortcutKey("Escape"), + shortcut_2: getTaggedShortcutKey("Enter"), + }); } if (activeTool.type === "arrow") { - return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") }); + return t("hints.arrowTool", { + shortcut: getTaggedShortcutKey("A"), + }); } return t("hints.linearElement"); } @@ -83,31 +96,51 @@ const getHints = ({ ) { const targetElement = selectedElements[0]; if (isLinearElement(targetElement) && targetElement.points.length === 2) { - return t("hints.lockAngle"); + return t("hints.lockAngle", { + shortcut: getTaggedShortcutKey("Shift"), + }); } return isImageElement(targetElement) - ? t("hints.resizeImage") - : t("hints.resize"); + ? t("hints.resizeImage", { + shortcut_1: getTaggedShortcutKey("Shift"), + shortcut_2: getTaggedShortcutKey("Alt"), + }) + : t("hints.resize", { + shortcut_1: getTaggedShortcutKey("Shift"), + shortcut_2: getTaggedShortcutKey("Alt"), + }); } if (isRotating && lastPointerDownWith === "mouse") { - return t("hints.rotate"); + return t("hints.rotate", { + shortcut: getTaggedShortcutKey("Shift"), + }); } if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { - return t("hints.text_selected"); + return t("hints.text_selected", { + shortcut: getTaggedShortcutKey("Enter"), + }); } 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) { - return t("hints.leaveCropEditor"); + return t("hints.leaveCropEditor", { + shortcut_1: getTaggedShortcutKey("Enter"), + shortcut_2: getTaggedShortcutKey("Escape"), + }); } if (selectedElements.length === 1 && isImageElement(selectedElements[0])) { - return t("hints.enterCropEditor"); + return t("hints.enterCropEditor", { + shortcut: getTaggedShortcutKey("Enter"), + }); } if (activeTool.type === "selection") { @@ -117,33 +150,57 @@ const getHints = ({ !appState.editingTextElement && !appState.selectedLinearElement?.isEditing ) { - return [t("hints.deepBoxSelect")]; + return t("hints.deepBoxSelect", { + shortcut: getTaggedShortcutKey("CtrlOrCmd"), + }); } if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { - return t("hints.disableSnapping"); + return t("hints.disableSnapping", { + shortcut: getTaggedShortcutKey("CtrlOrCmd"), + }); } 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 (isLinearElement(selectedElements[0])) { if (appState.selectedLinearElement?.isEditing) { return appState.selectedLinearElement.selectedPointsIndices - ? t("hints.lineEditor_pointSelected") - : t("hints.lineEditor_nothingSelected"); + ? t("hints.lineEditor_pointSelected", { + 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]) - ? t("hints.lineEditor_line_info") - : t("hints.lineEditor_info"); + ? t("hints.lineEditor_line_info", { + shortcut: getTaggedShortcutKey("Enter"), + }) + : t("hints.lineEditor_info", { + shortcut_1: getTaggedShortcutKey("CtrlOrCmd"), + shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]), + }); } if ( !appState.newElement && !appState.selectedElementsAreBeingDragged && isTextBindableContainer(selectedElements[0]) ) { + const bindTextToElement = t("hints.bindTextToElement", { + shortcut: getTaggedShortcutKey("Enter"), + }); + const createFlowchart = t("hints.createFlowchart", { + shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]), + }); if (isFlowchartNodeElement(selectedElements[0])) { if ( isNodeInFlowchart( @@ -151,13 +208,13 @@ const getHints = ({ 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) - ? hints - .map((hint) => { - return getShortcutKey(hint).replace(/\. ?$/, ""); - }) - .join(". ") - : getShortcutKey(hints); + ? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ") + : hints; + + const hintJSX = hint.split(/([^<]+<\/kbd>)/g).map((part, index) => { + if (index % 2 === 1) { + const shortcutMatch = + part[0] === "<" && part.match(/^([^<]+)<\/kbd>$/); + return {shortcutMatch ? shortcutMatch[1] : part}; + } + return part; + }); return (
    - {hint} + {hintJSX}
    ); }; diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 5630ae8d7a..031d181eb0 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -152,16 +152,14 @@ function Picker({ ); }; + const isMobile = device.editor.isMobile; + return (
    { +export const InlineIcon = ({ + className, + icon, + size = "1em", +}: { + className?: string; + icon: React.ReactNode; + size?: string; +}) => { return ( * { pointer-events: var(--ui-pointerEvents); } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index db0ab34297..84b3b8c4e2 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -4,6 +4,7 @@ import React from "react"; import { CLASSES, DEFAULT_SIDEBAR, + MQ_MIN_WIDTH_DESKTOP, TOOL_TYPE, arrayToMap, capitalizeString, @@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; -import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; +import { + SelectedShapeActions, + ShapesSwitcher, + CompactShapeActions, +} from "./Actions"; import { LoadingMessage } from "./LoadingMessage"; import { LockButton } from "./LockButton"; import { MobileMenu } from "./MobileMenu"; @@ -86,6 +91,7 @@ interface LayerUIProps { onPenModeToggle: AppClassProperties["togglePenMode"]; showExitZenModeBtn: boolean; langCode: Language["code"]; + renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; UIOptions: AppProps["UIOptions"]; @@ -144,6 +150,7 @@ const LayerUI = ({ onHandToolToggle, onPenModeToggle, showExitZenModeBtn, + renderTopLeftUI, renderTopRightUI, renderCustomStats, UIOptions, @@ -157,6 +164,25 @@ const LayerUI = ({ const device = useDevice(); 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 [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); @@ -209,31 +235,55 @@ const LayerUI = ({
    ); - const renderSelectedShapeActions = () => ( -
    - { + const isCompactMode = appState.stylesPanelMode === "compact"; + + return ( +
    - - -
    - ); + {isCompactMode ? ( + + + + ) : ( + + + + )} +
    + ); + }; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( @@ -250,9 +300,19 @@ const LayerUI = ({ return (
    - + {renderCanvasActions()} - {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} +
    + {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} +
    {!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && ( @@ -262,17 +322,19 @@ const LayerUI = ({ {renderWelcomeScreen && ( )} - + {heading} - + @@ -418,7 +482,9 @@ const LayerUI = ({ }} tab={DEFAULT_SIDEBAR.defaultTab} > - {t("toolBar.library")} + {appState.stylesPanelMode === "full" && + appState.width >= MQ_MIN_WIDTH_DESKTOP && + t("toolBar.library")} {appState.openDialog?.name === "ttd" && } @@ -518,13 +584,11 @@ const LayerUI = ({ renderJSONExportDialog={renderJSONExportDialog} renderImageExportDialog={renderImageExportDialog} setAppState={setAppState} - onLockToggle={onLockToggle} onHandToolToggle={onHandToolToggle} onPenModeToggle={onPenModeToggle} + renderTopLeftUI={renderTopLeftUI} renderTopRightUI={renderTopRightUI} - renderCustomStats={renderCustomStats} renderSidebars={renderSidebars} - device={device} renderWelcomeScreen={renderWelcomeScreen} UIOptions={UIOptions} /> diff --git a/packages/excalidraw/components/LibraryMenu.scss b/packages/excalidraw/components/LibraryMenu.scss index ac2c354b05..d0d33befd8 100644 --- a/packages/excalidraw/components/LibraryMenu.scss +++ b/packages/excalidraw/components/LibraryMenu.scss @@ -133,15 +133,10 @@ } .layer-ui__library .library-menu-dropdown-container { + z-index: 1; position: relative; - &--in-heading { - padding: 0; - position: absolute; - top: 1rem; - right: 0.75rem; - z-index: 1; - + margin-left: auto; .dropdown-menu { top: 100%; } diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 62bd235c26..9a4f29f179 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -11,6 +11,11 @@ import { LIBRARY_DISABLED_TYPES, randomId, isShallowEqual, + KEYS, + isWritableElement, + addEventListener, + EVENT, + CLASSES, } from "@excalidraw/common"; import type { @@ -266,11 +271,52 @@ export const LibraryMenu = memo(() => { const memoizedLibrary = useMemo(() => app.library, [app.library]); 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( (libraryItems: LibraryItems) => { onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + app.focusContainer(); }, - [onInsertElements], + [onInsertElements, app], ); const deselectItems = useCallback(() => { diff --git a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx index 5b003effa1..9d7e0d1c84 100644 --- a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx +++ b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx @@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{ {t("buttons.export")} )} - {!!items.length && ( - setShowRemoveLibAlert(true)} - icon={TrashIcon} - > - {resetLabel} - - )} {itemsSelected && ( )} + {!!items.length && ( + setShowRemoveLibAlert(true)} + icon={TrashIcon} + > + {resetLabel} + + )} ); diff --git a/packages/excalidraw/components/LibraryMenuItems.scss b/packages/excalidraw/components/LibraryMenuItems.scss index 59cd9f1cf9..3e67774348 100644 --- a/packages/excalidraw/components/LibraryMenuItems.scss +++ b/packages/excalidraw/components/LibraryMenuItems.scss @@ -1,24 +1,42 @@ @import "open-color/open-color"; .excalidraw { - --container-padding-y: 1.5rem; + --container-padding-y: 1rem; --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 { text-align: center; color: var(--color-gray-70); line-height: 1.5; font-size: 0.875rem; width: 100%; + min-height: 55px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; &__label { color: var(--color-primary); font-weight: 700; 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 { .library-menu-items__no-items { color: var(--color-gray-40); @@ -34,7 +52,7 @@ overflow-y: auto; flex-direction: column; height: 100%; - justify-content: center; + justify-content: flex-start; margin: 0; position: relative; @@ -51,26 +69,45 @@ } &__items { + // so that spinner is relative-positioned to this container + position: relative; + row-gap: 0.5rem; - padding: var(--container-padding-y) 0; + padding: 1rem 0 var(--container-padding-y) 0; flex: 1; overflow-y: auto; overflow-x: hidden; - margin-bottom: 1rem; } &__header { + display: flex; + align-items: center; + flex: 1 1 auto; + color: var(--color-primary); font-size: 1.125rem; font-weight: 700; margin-bottom: 0.75rem; width: 100%; - padding-right: 4rem; // due to dropdown button box-sizing: border-box; &--excal { 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 { @@ -79,6 +116,24 @@ 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 { width: 100%; display: flex; diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index 8e06632aad..c64351b1b3 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -6,11 +6,14 @@ import React, { useState, } from "react"; -import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; +import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common"; import { duplicateElements } from "@excalidraw/element"; -import { serializeLibraryAsJSON } from "../data/json"; +import clsx from "clsx"; + +import { deburr } from "../deburr"; + import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; @@ -27,6 +30,14 @@ import Stack from "./Stack"; import "./LibraryMenuItems.scss"; +import { TextField } from "./TextField"; + +import { useDevice } from "./App"; + +import { Button } from "./Button"; + +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { ExcalidrawProps, LibraryItem, @@ -64,6 +75,7 @@ export default function LibraryMenuItems({ selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; }) { + const device = useDevice(); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -75,6 +87,30 @@ export default function LibraryMenuItems({ }, []); // eslint-disable-line react-hooks/exhaustive-deps 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( () => libraryItems.filter((item) => item.status !== "published"), [libraryItems], @@ -85,23 +121,10 @@ export default function LibraryMenuItems({ [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( (id: LibraryItem["id"], event: React.MouseEvent) => { const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; - if (shouldSelect) { if (event.shiftKey && lastSelectedItem) { const rangeStart = orderedItems.findIndex( @@ -115,10 +138,13 @@ export default function LibraryMenuItems({ } 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( (acc: LibraryItem["id"][], item, idx) => { if ( - (idx >= rangeStart && idx <= rangeEnd) || + (idx >= minRange && idx <= maxRange) || selectedItemsMap.has(item.id) ) { acc.push(item.id); @@ -127,7 +153,6 @@ export default function LibraryMenuItems({ }, [], ); - onSelectItems(nextSelectedIds); } else { 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( (id: string) => { let targetElements; @@ -175,12 +208,17 @@ export default function LibraryMenuItems({ const onItemDrag = useCallback( (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( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), + MIME_TYPES.excalidrawlibIds, + JSON.stringify(data), ); }, - [getInsertedElements], + [selectedItems], ); const isItemSelected = useCallback( @@ -188,7 +226,6 @@ export default function LibraryMenuItems({ if (!id) { return false; } - return selectedItems.includes(id); }, [selectedItems], @@ -208,10 +245,136 @@ export default function LibraryMenuItems({ ); const itemsRenderedPerBatch = - svgCache.size >= libraryItems.length + svgCache.size >= + (filteredItems.length ? filteredItems : libraryItems).length ? CACHED_ITEMS_RENDERED_PER_BATCH : ITEMS_RENDERED_PER_BATCH; + const searchInputRef = useRef(null); + useEffect(() => { + // focus could be stolen by tab trigger button + nextAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + }, []); + + const JSX_whenNotSearching = !IS_SEARCHING && ( + <> + {!IS_LIBRARY_EMPTY && ( +
    + {t("labels.personalLib")} +
    + )} + {!pendingElements.length && !unpublishedItems.length ? ( +
    + {!publishedItems.length && ( +
    + {t("library.noItems")} +
    + )} +
    + {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
    +
    + ) : ( + + {pendingElements.length > 0 && ( + + )} + + + )} + + {publishedItems.length > 0 && ( +
    + {t("labels.excalidrawLib")} +
    + )} + {publishedItems.length > 0 && ( + + + + )} + + ); + + const JSX_whenSearching = IS_SEARCHING && ( + <> +
    + {t("library.search.heading")} + {!isLoading && ( +
    e.preventDefault()} + onClick={(event) => { + setSearchInputValue(""); + }} + > + esc to clear +
    + )} +
    + {filteredItems.length > 0 ? ( + + + + ) : ( +
    +
    + {t("library.search.noResults")} +
    + +
    + )} + + ); + return (
    - {!isLibraryEmpty && ( +
    + {!IS_LIBRARY_EMPTY && ( + setSearchInputValue(value)} + /> + )} - )} +
    0 ? 1 : "0 1 auto", - marginBottom: 0, + margin: IS_LIBRARY_EMPTY ? "auto" : 0, }} ref={libraryContainerRef} > - <> - {!isLibraryEmpty && ( -
    - {t("labels.personalLib")} -
    - )} - {isLoading && ( -
    - -
    - )} - {!pendingElements.length && !unpublishedItems.length ? ( -
    -
    - {t("library.noItems")} -
    -
    - {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} -
    -
    - ) : ( - - {pendingElements.length > 0 && ( - - )} - - - )} - + {isLoading && ( +
    + +
    + )} - <> - {(publishedItems.length > 0 || - pendingElements.length > 0 || - unpublishedItems.length > 0) && ( -
    - {t("labels.excalidrawLib")} -
    - )} - {publishedItems.length > 0 ? ( - - - - ) : unpublishedItems.length > 0 ? ( -
    - {t("library.noItems")} -
    - ) : null} - + {JSX_whenNotSearching} + {JSX_whenSearching} - {showBtn && ( + {IS_LIBRARY_EMPTY && ( - - + /> )}
    diff --git a/packages/excalidraw/components/LibraryMenuSection.tsx b/packages/excalidraw/components/LibraryMenuSection.tsx index d98b413fbb..9ff84f5724 100644 --- a/packages/excalidraw/components/LibraryMenuSection.tsx +++ b/packages/excalidraw/components/LibraryMenuSection.tsx @@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg"; import type { LibraryItem } from "../types"; import type { ReactNode } from "react"; -type LibraryOrPendingItem = ( +type LibraryOrPendingItem = readonly ( | LibraryItem | /* pending library item */ { id: null; diff --git a/packages/excalidraw/components/LibraryUnit.scss b/packages/excalidraw/components/LibraryUnit.scss index 5ebe83f414..a0d2161c21 100644 --- a/packages/excalidraw/components/LibraryUnit.scss +++ b/packages/excalidraw/components/LibraryUnit.scss @@ -18,12 +18,12 @@ } &--hover { - border-color: var(--color-primary); + background-color: var(--color-surface-mid); } + &:active:not(:has(.library-unit__checkbox:hover)), &--selected { - border-color: var(--color-primary); - border-width: 1px; + background-color: var(--color-surface-high); } &--skeleton { diff --git a/packages/excalidraw/components/LibraryUnit.tsx b/packages/excalidraw/components/LibraryUnit.tsx index 9cd891715c..36607910e5 100644 --- a/packages/excalidraw/components/LibraryUnit.tsx +++ b/packages/excalidraw/components/LibraryUnit.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useRef, useState } from "react"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; @@ -33,23 +33,7 @@ export const LibraryUnit = memo( svgCache: SvgCache; }) => { const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements, svgCache); - - useEffect(() => { - const node = ref.current; - - if (!node) { - return; - } - - if (svg) { - node.innerHTML = svg.outerHTML; - } - - return () => { - node.innerHTML = ""; - }; - }, [svg]); + const svg = useLibraryItemSvg(id, elements, svgCache, ref); const [isHovered, setIsHovered] = useState(false); const isMobile = useDevice().editor.isMobile; diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index 9f74e75555..e7c947850b 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -1,32 +1,23 @@ import React from "react"; -import { showSelectedShapeActions } from "@excalidraw/element"; - import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; -import { isHandToolActive } from "../appState"; import { useTunnels } from "../context/tunnels"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; 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 { HandButton } from "./HandButton"; -import { HintViewer } from "./HintViewer"; + 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 { AppClassProperties, AppProps, AppState, - Device, - ExcalidrawProps, UIAppState, } from "../types"; import type { JSX } from "react"; @@ -38,7 +29,6 @@ type MobileMenuProps = { renderImageExportDialog: () => React.ReactNode; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; - onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"]; @@ -46,9 +36,11 @@ type MobileMenuProps = { isMobile: boolean, appState: UIAppState, ) => JSX.Element | null; - renderCustomStats?: ExcalidrawProps["renderCustomStats"]; + renderTopLeftUI?: ( + isMobile: boolean, + appState: UIAppState, + ) => JSX.Element | null; renderSidebars: () => JSX.Element | null; - device: Device; renderWelcomeScreen: boolean; UIOptions: AppProps["UIOptions"]; app: AppClassProperties; @@ -59,14 +51,10 @@ export const MobileMenu = ({ elements, actionManager, setAppState, - onLockToggle, onHandToolToggle, - onPenModeToggle, - + renderTopLeftUI, renderTopRightUI, - renderCustomStats, renderSidebars, - device, renderWelcomeScreen, UIOptions, app, @@ -76,142 +64,99 @@ export const MobileMenu = ({ MainMenuTunnel, DefaultSidebarTriggerTunnel, } = useTunnels(); - const renderToolbar = () => { - return ( - - {renderWelcomeScreen && } -
    - {(heading: React.ReactNode) => ( - - - - {heading} - - - - - {renderTopRightUI && renderTopRightUI(true, appState)} -
    - {!appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && ( - - )} - onPenModeToggle(null)} - title={t("toolBar.penMode")} - isMobile - penDetected={appState.penDetected} - /> - - onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> -
    -
    -
    - )} -
    - -
    + const renderAppTopBar = () => { + const topRightUI = renderTopRightUI?.(true, appState) ?? ( + + ); + + const topLeftUI = ( +
    + {renderTopLeftUI?.(true, appState)} + +
    ); - }; - const renderAppToolbar = () => { if ( appState.viewModeEnabled || appState.openDialog?.name === "elementLinkSelector" ) { - return ( -
    - -
    - ); + return
    {topLeftUI}
    ; } return ( -
    - - {actionManager.renderAction("toggleEditMenu")} - {actionManager.renderAction( - appState.multiElement ? "finalize" : "duplicateSelection", - )} - {actionManager.renderAction("deleteSelectedElements")} -
    - {actionManager.renderAction("undo")} - {actionManager.renderAction("redo")} -
    +
    + {topLeftUI} + {topRightUI}
    ); }; + const renderToolbar = () => { + return ( + + ); + }; + return ( <> {renderSidebars()} - {!appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && - renderToolbar()} + {/* welcome screen, bottom bar, and top bar all have the same z-index */} + {/* ordered in this reverse order so that top bar is on top */} +
    + {renderWelcomeScreen && } +
    +
    - - {appState.openMenu === "shape" && - !appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && - showSelectedShapeActions(appState, elements) ? ( -
    - -
    - ) : null} -
    - {renderAppToolbar()} - {appState.scrolledOutside && - !appState.openMenu && - !appState.openSidebar && - !appState.scrollConstraints && ( - - )} -
    + + + + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + renderToolbar()} + {appState.scrolledOutside && + !appState.openMenu && + !appState.openSidebar && + !appState.scrollConstraints && ( + + )}
    + + + {renderAppTopBar()} + ); }; diff --git a/packages/excalidraw/components/MobileToolBar.scss b/packages/excalidraw/components/MobileToolBar.scss new file mode 100644 index 0000000000..b936c70ebd --- /dev/null +++ b/packages/excalidraw/components/MobileToolBar.scss @@ -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; + } +} diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx new file mode 100644 index 0000000000..bc52c01b71 --- /dev/null +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -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["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(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 ( +
    + {/* Hand Tool */} + + + {/* Selection Tool */} + { + 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 */} + handleToolChange("freedraw")} + /> + + {/* Eraser */} + handleToolChange("eraser")} + /> + + {/* Rectangle */} + { + 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 */} + { + 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 && ( + handleToolChange("text")} + /> + )} + + {/* Image */} + {showImageToolOutside && ( + handleToolChange("image")} + /> + )} + + {/* Frame Tool */} + {showFrameToolOutside && ( + handleToolChange("frame")} + /> + )} + + {/* Other Shapes */} + + { + setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen); + setAppState({ openMenu: null, openPopup: null }); + }} + title={t("toolBar.extraTools")} + style={{ + width: WIDTH, + height: WIDTH, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {extraIcon} + + setIsOtherShapesMenuOpen(false)} + onSelect={() => setIsOtherShapesMenuOpen(false)} + className="App-toolbar__extra-tools-dropdown" + > + {!showTextToolOutside && ( + app.setActiveTool({ type: "text" })} + icon={TextIcon} + shortcut={KEYS.T.toLocaleUpperCase()} + data-testid="toolbar-text" + selected={activeTool.type === "text"} + > + {t("toolBar.text")} + + )} + + {!showImageToolOutside && ( + app.setActiveTool({ type: "image" })} + icon={ImageIcon} + data-testid="toolbar-image" + selected={activeTool.type === "image"} + > + {t("toolBar.image")} + + )} + {!showFrameToolOutside && ( + app.setActiveTool({ type: "frame" })} + icon={frameToolIcon} + shortcut={KEYS.F.toLocaleUpperCase()} + data-testid="toolbar-frame" + selected={frameToolSelected} + > + {t("toolBar.frame")} + + )} + app.setActiveTool({ type: "embeddable" })} + icon={EmbedIcon} + data-testid="toolbar-embeddable" + selected={embeddableToolSelected} + > + {t("toolBar.embeddable")} + + app.setActiveTool({ type: "laser" })} + icon={laserPointerToolIcon} + data-testid="toolbar-laser" + selected={laserToolSelected} + shortcut={KEYS.K.toLocaleUpperCase()} + > + {t("toolBar.laser")} + +
    + Generate +
    + {app.props.aiEnabled !== false && } + app.setOpenDialog({ name: "ttd", tab: "mermaid" })} + icon={mermaidLogoIcon} + data-testid="toolbar-embeddable" + > + {t("toolBar.mermaidToExcalidraw")} + + {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( + <> + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + > + {t("toolBar.magicframe")} + AI + + + )} +
    +
    +
    + ); +}; diff --git a/packages/excalidraw/components/Popover.tsx b/packages/excalidraw/components/Popover.tsx index 4864b37d16..d8ee76aa62 100644 --- a/packages/excalidraw/components/Popover.tsx +++ b/packages/excalidraw/components/Popover.tsx @@ -3,6 +3,8 @@ import { unstable_batchedUpdates } from "react-dom"; import { KEYS, queryFocusableElements } from "@excalidraw/common"; +import clsx from "clsx"; + import "./Popover.scss"; type Props = { @@ -15,6 +17,7 @@ type Props = { offsetTop?: number; viewportWidth?: number; viewportHeight?: number; + className?: string; }; export const Popover = ({ @@ -27,6 +30,7 @@ export const Popover = ({ offsetTop = 0, viewportWidth = window.innerWidth, viewportHeight = window.innerHeight, + className, }: Props) => { const popoverRef = useRef(null); @@ -146,7 +150,7 @@ export const Popover = ({ }, [onCloseRequest]); return ( -
    +
    {children}
    ); diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index d8372ea27b..3c03c35b99 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -17,6 +17,7 @@ interface PropertiesPopoverProps { onPointerLeave?: React.PointerEventHandler; onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"]; onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"]; + preventAutoFocusOnTouch?: boolean; } export const PropertiesPopover = React.forwardRef< @@ -34,6 +35,7 @@ export const PropertiesPopover = React.forwardRef< onFocusOutside, onPointerLeave, onPointerDownOutside, + preventAutoFocusOnTouch = false, }, ref, ) => { @@ -58,12 +60,19 @@ export const PropertiesPopover = React.forwardRef< alignOffset={-16} sideOffset={20} style={{ - zIndex: "var(--zIndex-popup)", + zIndex: "var(--zIndex-ui-styles-popup)", + marginLeft: device.editor.isMobile ? "0.5rem" : undefined, }} onPointerLeave={onPointerLeave} onKeyDown={onKeyDown} onFocusOutside={onFocusOutside} onPointerDownOutside={onPointerDownOutside} + onOpenAutoFocus={(e) => { + // prevent auto-focus on touch devices to avoid keyboard popup + if (preventAutoFocusOnTouch && device.isTouchScreen) { + e.preventDefault(); + } + }} onCloseAutoFocus={(e) => { e.stopPropagation(); // prevents focusing the trigger diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index 076b303d70..cdc038dac3 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -518,7 +518,7 @@ const PublishLibrary = ({
    diff --git a/packages/excalidraw/components/Sidebar/Sidebar.scss b/packages/excalidraw/components/Sidebar/Sidebar.scss index c7776d1c69..2fba020ca9 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.scss +++ b/packages/excalidraw/components/Sidebar/Sidebar.scss @@ -9,7 +9,7 @@ top: 0; bottom: 0; right: 0; - z-index: 5; + z-index: var(--zIndex-ui-library); margin: 0; padding: 0; box-sizing: border-box; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx index d08ba5f597..5f0ca487f2 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx @@ -9,7 +9,13 @@ import React, { useCallback, } 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 { atom, useSetAtom } from "../../editor-jotai"; @@ -137,7 +143,11 @@ export const SidebarInner = forwardRef( return ( diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 6e8bf374ce..706a6abe52 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -30,7 +30,11 @@ export const SidebarTrigger = ({ .querySelector(".layer-ui__wrapper") ?.classList.remove("animate"); const isOpen = event.target.checked; - setAppState({ openSidebar: isOpen ? { name, tab } : null }); + setAppState({ + openSidebar: isOpen ? { name, tab } : null, + openMenu: null, + openPopup: null, + }); onToggle?.(isOpen); }} checked={appState.openSidebar?.name === name} diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx index 05cad640b8..21a6f16948 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx @@ -1,4 +1,4 @@ -import { getShortcutKey } from "@excalidraw/common"; +import { getShortcutKey } from "@excalidraw/excalidraw/shortcut"; export const TTDDialogSubmitShortcut = () => { return ( diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx index 833b659fe2..0d5c62f331 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx @@ -1,12 +1,11 @@ import { trackEvent } from "../../analytics"; import { useTunnels } from "../../context/tunnels"; -import { t } from "../../i18n"; +import { useI18n } from "../../i18n"; import { useExcalidrawSetAppState } from "../App"; import DropdownMenu from "../dropdownMenu/DropdownMenu"; import { brainIcon } from "../icons"; -import type { ReactNode } from "react"; -import type { JSX } from "react"; +import type { JSX, ReactNode } from "react"; export const TTDDialogTrigger = ({ children, @@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({ children?: ReactNode; icon?: JSX.Element; }) => { + const { t } = useI18n(); const { TTDDialogTriggerTunnel } = useTunnels(); const setAppState = useExcalidrawSetAppState(); diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss index c46cd2fe8c..fefea7e802 100644 --- a/packages/excalidraw/components/TextField.scss +++ b/packages/excalidraw/components/TextField.scss @@ -12,6 +12,10 @@ --ExcTextField--border-active: var(--color-brand-active); --ExcTextField--placeholder: var(--color-border-outline-variant); + &.theme--dark { + --ExcTextField--border: var(--color-border-outline-variant); + } + .ExcTextField { position: relative; diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index d6bc315b18..4e724aceda 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -28,6 +28,7 @@ type TextFieldProps = { className?: string; placeholder?: string; isRedacted?: boolean; + type?: "text" | "search"; } & ({ value: string } | { defaultValue: string }); export const TextField = forwardRef( @@ -43,6 +44,7 @@ export const TextField = forwardRef( isRedacted = false, icon, className, + type, ...rest }, ref, @@ -96,6 +98,7 @@ export const TextField = forwardRef( ref={innerRef} onChange={(event) => onChange?.(event.target.value)} onKeyDown={onKeyDown} + type={type} /> {isRedacted && (