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 b972e6e5b0..a5d01769cc 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -119,6 +119,7 @@ import { LibraryIndexedDBAdapter, LibraryLocalStorageMigrationAdapter, LocalData, + localStorageQuotaExceededAtom, } from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; @@ -727,6 +728,8 @@ const ExcalidrawWrapper = () => { const isOffline = useAtomValue(isOfflineAtom); + const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom); + const onCollabDialogOpen = useCallback( () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), [setShareDialogState], @@ -901,10 +904,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/index.scss b/excalidraw-app/index.scss index cfaaf9cea2..9f320775be 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -58,7 +58,7 @@ } } - .collab-offline-warning { + .alert { pointer-events: none; position: absolute; top: 6.5rem; @@ -69,10 +69,18 @@ text-align: center; line-height: 1.5; border-radius: var(--border-radius-md); - background-color: var(--color-warning); - color: var(--color-text-warning); z-index: 6; white-space: pre; + + &--warning { + background-color: var(--color-warning); + color: var(--color-text-warning); + } + + &--danger { + background-color: var(--color-danger-dark); + color: var(--color-danger-text); + } } } 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 c7b309379d..618d9ca078 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -125,6 +125,7 @@ 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", @@ -266,7 +267,10 @@ export const STRING_MIME_TYPES = { 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 = { @@ -351,6 +355,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { // 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) @@ -541,3 +548,8 @@ export enum UserIdleState { 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 8130482db5..7bf73c6581 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -20,7 +20,6 @@ import { ENV, FONT_FAMILY, getFontFamilyFallbacks, - isDarwin, isAndroid, isIOS, WINDOWS_EMOJI_FALLBACK_FONT, @@ -93,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, @@ -121,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, @@ -420,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 }, { 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 6b190de1b7..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; diff --git a/packages/element/src/comparisons.ts b/packages/element/src/comparisons.ts index 88b21081e4..9e0aa8d5cb 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/index.ts b/packages/element/src/index.ts index 4fc1ef5579..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++) { 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/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/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 80ae90b3aa..8b47587fec 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -8,6 +8,7 @@ import { } from "@excalidraw/common"; import { getOriginalContainerHeightFromCache, + isBoundToContainer, resetOriginalContainerCache, updateOriginalContainerCache, } from "@excalidraw/element"; @@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({ trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); - const someTextElements = selectedElements.some((el) => isTextElement(el)); + const someTextElements = selectedElements.some( + (el) => isTextElement(el) && !isBoundToContainer(el), + ); return selectedElements.length > 0 && someTextElements; }, perform: (elements, appState, _, app) => { @@ -234,7 +237,7 @@ export const actionWrapTextInContainer = register({ const containerIds: Mutable = {}; for (const textElement of selectedElements) { - if (isTextElement(textElement)) { + if (isTextElement(textElement) && !isBoundToContainer(textElement)) { const container = newElement({ type: "rectangle", backgroundColor: appState.currentItemBackgroundColor, diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index d0039d1c29..b0760fd8ba 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, @@ -46,6 +45,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"; @@ -122,7 +122,10 @@ export const actionClearCanvas = register({ pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" - ? { ...appState.activeTool, type: app.defaultSelectionTool } + ? { + ...appState.activeTool, + type: app.state.preferredSelectionTool.type, + } : appState.activeTool, }, captureUpdate: CaptureUpdateAction.IMMEDIATELY, @@ -501,7 +504,7 @@ export const actionToggleEraserTool = register({ if (isEraserActive(appState)) { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }), lastActiveToolBeforeEraser: null, }); @@ -532,7 +535,7 @@ export const actionToggleLassoTool = register({ icon: LassoIcon, trackEvent: { category: "toolbar" }, predicate: (elements, appState, props, app) => { - return app.defaultSelectionTool !== "lasso"; + 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 78a3465689..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"; @@ -299,7 +303,7 @@ export const actionDeleteSelected = register({ appState: { ...nextAppState, activeTool: updateActiveTool(appState, { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }), multiElement: null, activeEmbeddable: null, @@ -323,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 877c817ad4..4e7ae67919 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -261,13 +261,13 @@ export const actionFinalize = register({ if (appState.activeTool.type === "eraser") { activeTool = updateActiveTool(appState, { ...(appState.activeTool.lastActiveTool || { - type: app.defaultSelectionTool, + type: app.state.preferredSelectionTool.type, }), lastActiveToolBeforeEraser: null, }); } else { activeTool = updateActiveTool(appState, { - type: app.defaultSelectionTool, + 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/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 e428a60646..47d6dd90d3 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, @@ -149,6 +148,8 @@ import { restoreCaretPosition, } from "../hooks/useTextEditorFocus"; +import { getShortcutKey } from "../shortcut"; + import { register } from "./register"; import type { AppClassProperties, AppState, Primitive } from "../types"; @@ -355,7 +356,10 @@ export const actionChangeStrokeColor = register({ elements={elements} appState={appState} updateData={updateData} - compactMode={appState.stylesPanelMode === "compact"} + compactMode={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } /> ), @@ -435,7 +439,10 @@ export const actionChangeBackgroundColor = register({ elements={elements} appState={appState} updateData={updateData} - compactMode={appState.stylesPanelMode === "compact"} + compactMode={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } /> ), @@ -538,9 +545,7 @@ export const actionChangeStrokeWidth = register({ }, PanelComponent: ({ elements, appState, updateData, app, data }) => (
- {appState.stylesPanelMode === "full" && ( - {t("labels.strokeWidth")} - )} + {t("labels.strokeWidth")}
(
- {appState.stylesPanelMode === "full" && ( - {t("labels.sloppiness")} - )} + {t("labels.sloppiness")}
(
- {appState.stylesPanelMode === "full" && ( - {t("labels.strokeStyle")} - )} + {t("labels.strokeStyle")}
{ withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, data?.onPreventClose, ); @@ -1047,7 +1049,7 @@ export const actionChangeFontFamily = register({ return result; }, - PanelComponent: ({ elements, appState, app, updateData, data }) => { + PanelComponent: ({ elements, appState, app, updateData }) => { const cachedElementsRef = useRef(new Map()); const prevSelectedFontFamilyRef = useRef(null); // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them @@ -1124,7 +1126,7 @@ export const actionChangeFontFamily = register({ }, []); return ( -
+ <> {appState.stylesPanelMode === "full" && ( {t("labels.fontFamily")} )} @@ -1132,7 +1134,7 @@ export const actionChangeFontFamily = register({ isOpened={appState.openPopup === "fontFamily"} selectedFontFamily={selectedFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily} - compactMode={appState.stylesPanelMode === "compact"} + compactMode={appState.stylesPanelMode !== "full"} onSelect={(fontFamily) => { withCaretPositionPreservation( () => { @@ -1144,7 +1146,8 @@ export const actionChangeFontFamily = register({ // defensive clear so immediate close won't abuse the cached elements cachedElementsRef.current.clear(); }, - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, ); }} @@ -1220,7 +1223,8 @@ export const actionChangeFontFamily = register({ // Refocus text editor when font picker closes if we were editing text if ( - appState.stylesPanelMode === "compact" && + (appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile") && appState.editingTextElement ) { restoreCaretPosition(null); // Just refocus without saved position @@ -1228,7 +1232,7 @@ export const actionChangeFontFamily = register({ } }} /> -
+ ); }, }); @@ -1321,7 +1325,8 @@ export const actionChangeTextAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, data?.onPreventClose, ); @@ -1420,7 +1425,8 @@ export const actionChangeVerticalAlign = register({ onChange={(value) => { withCaretPositionPreservation( () => updateData(value), - appState.stylesPanelMode === "compact", + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile", !!appState.editingTextElement, data?.onPreventClose, ); @@ -1834,8 +1840,8 @@ export const actionChangeArrowProperties = register({ PanelComponent: ({ elements, appState, updateData, app, renderAction }) => { return (
- {renderAction("changeArrowType")} {renderAction("changeArrowhead")} + {renderAction("changeArrowType")}
); }, 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 2e06f1c752..1b2b3e9d51 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -45,11 +45,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 3aeb0b158e..430a04c71d 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -73,8 +73,6 @@ export type ActionName = | "changeArrowProperties" | "changeOpacity" | "changeFontSize" - | "toggleCanvasMenu" - | "toggleEditMenu" | "undo" | "redo" | "finalize" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index f72d313594..c5b5d27a7f 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -56,6 +56,10 @@ export const getDefaultAppState = (): Omit< fromSelection: false, lastActiveTool: null, }, + preferredSelectionTool: { + type: "selection", + initialized: false, + }, penMode: false, penDetected: false, errorMessage: null, @@ -178,6 +182,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 }, @@ -250,7 +255,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: true, export: false, server: false }, + stylesPanelMode: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 007a02161b..ae532a6c27 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -470,13 +470,14 @@ export const parseDataTransferEvent = async ( Array.from(items || []).map( async (item): Promise => { if (item.kind === "file") { - const file = item.getAsFile(); + let file = item.getAsFile(); if (file) { const fileHandle = await getFileHandle(item); + file = await normalizeFile(file); return { type: file.type, kind: "file", - file: await normalizeFile(file), + file, fileHandle, }; } diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index 93b5ef7c3e..f97f3c7b6f 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -106,15 +106,15 @@ justify-content: center; align-items: center; min-height: 2.5rem; + pointer-events: auto; --default-button-size: 2rem; .compact-action-button { - width: 2rem; - height: 2rem; + width: var(--mobile-action-button-size); + height: var(--mobile-action-button-size); border: none; border-radius: var(--border-radius-lg); - background: transparent; color: var(--color-on-surface); cursor: pointer; display: flex; @@ -122,24 +122,20 @@ justify-content: center; transition: all 0.2s ease; + background: var(--mobile-action-button-bg); + svg { width: 1rem; height: 1rem; flex: 0 0 auto; } - &:hover { - background: var(--button-hover-bg, var(--island-bg-color)); - border-color: var( - --button-hover-border, - var(--button-border, var(--default-border-color)) + &.active { + background: var( + --color-surface-primary-container, + var(--mobile-action-button-bg) ); } - - &:active { - background: var(--button-active-bg, var(--island-bg-color)); - border-color: var(--button-active-border, var(--color-primary-darkest)); - } } .compact-popover-content { @@ -167,6 +163,19 @@ } } } + + .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 { @@ -174,29 +183,18 @@ overflow-x: hidden; } -.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; - } - } +.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 { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 8c1bc355f5..196a1883ad 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useState } from "react"; +import { useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { @@ -15,19 +15,16 @@ import { isFlowchartNodeElement, 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, @@ -62,6 +59,7 @@ import "./Actions.scss"; 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"; @@ -79,8 +77,11 @@ import { TextSizeIcon, adjustmentsIcon, DotsHorizontalIcon, + SelectionIcon, } from "./icons"; +import { Island } from "./Island"; + import type { AppClassProperties, AppProps, @@ -325,6 +326,492 @@ 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")} + {hasContainerBehavior(appState.activeTool.type) || + (targetElements.some( + (element) => + isFlowchartNodeElement(element) && + hasBoundTextElement(element), + ) && <>{renderAction("changeContainerBehavior")})} + {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"; + + const textContainer = + targetElements.length === 1 && isTextElement(targetElements[0]) + ? getContainerElement(targetElements[0], elementsMap) + : null; + + const isStickyNoteContainer = + textContainer && isFlowchartNodeElement(textContainer); + + 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")} + {isStickyNoteContainer && ( + <>{renderAction("changeContainerBehavior")} + )} +
+
+ )} +
+
+ ); +}; + +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, @@ -339,7 +826,6 @@ export const CompactShapeActions = ({ setAppState: React.Component["setState"]; }) => { const targetElements = getTargetElements(elementsMap, appState); - const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); const { container } = useExcalidrawContainer(); const isEditingTextOrNewElement = Boolean( @@ -370,24 +856,6 @@ export const CompactShapeActions = ({ isLinearElement(targetElements[0]) && !isElbowArrow(targetElements[0]); - const showCropEditorAction = - !appState.croppingElementId && - targetElements.length === 1 && - isImageElement(targetElements[0]); - - 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"; - return (
{/* Stroke Color */} @@ -404,162 +872,22 @@ export const CompactShapeActions = ({
)} - {/* Combined Properties (Fill, Stroke, Opacity) */} - {(showFillIcons || - hasStrokeWidth(appState.activeTool.type) || - targetElements.some((element) => hasStrokeWidth(element.type)) || - hasStrokeStyle(appState.activeTool.type) || - targetElements.some((element) => hasStrokeStyle(element.type)) || - canChangeRoundness(appState.activeTool.type) || - targetElements.some((element) => canChangeRoundness(element.type))) && ( -
- { - if (open) { - setAppState({ openPopup: "compactStrokeStyles" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactStrokeStyles" && ( - {}} - > -
- {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")} - {hasContainerBehavior(appState.activeTool.type) || - (targetElements.some( - (element) => - isFlowchartNodeElement(element) && - hasBoundTextElement(element), - ) && <>{renderAction("changeContainerBehavior")})} - {renderAction("changeOpacity")} -
-
- )} -
-
- )} - - {/* Combined Arrow Properties */} - {(toolIsArrow(appState.activeTool.type) || - targetElements.some((element) => toolIsArrow(element.type))) && ( -
- { - if (open) { - setAppState({ openPopup: "compactArrowProperties" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactArrowProperties" && ( - {}} - > - {renderAction("changeArrowProperties")} - - )} - -
- )} + + {/* Linear Editor */} {showLineEditorAction && (
@@ -574,76 +902,14 @@ export const CompactShapeActions = ({
{renderAction("changeFontFamily")}
-
- { - 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")} - {isStickyNoteContainer && ( - <>{renderAction("changeContainerBehavior")} - )} -
-
- )} -
-
+ )} @@ -661,135 +927,195 @@ export const CompactShapeActions = ({
)} - {/* Combined Other Actions */} - {!isEditingTextOrNewElement && targetElements.length > 0 && ( -
- { - if (open) { - setAppState({ openPopup: "compactOtherProperties" }); - } else { - setAppState({ openPopup: null }); - } - }} - > - - - - {appState.openPopup === "compactOtherProperties" && ( - {}} - > -
-
- {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")} -
-
-
-
- )} -
-
- )} +
); }; +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" && app.defaultSelectionTool !== "lasso"; + app.state.stylesPanelMode === "full" && + activeTool.type === "lasso" && + app.state.preferredSelectionTool.type !== "lasso"; const embeddableToolSelected = activeTool.type === "embeddable"; @@ -816,6 +1142,40 @@ export const ShapesSwitcher = ({ 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] + } + fillable={activeTool.type === "selection"} + /> + ); + } return ( { - if (!appState.penDetected && pointerType === "pen") { + if (!app.state.penDetected && pointerType === "pen") { app.togglePenMode(true); } if (value === "selection") { - if (appState.activeTool.type === "selection") { + if (app.state.activeTool.type === "selection") { app.setActiveTool({ type: "lasso" }); } else { app.setActiveTool({ type: "selection" }); @@ -844,7 +1204,7 @@ export const ShapesSwitcher = ({ } }} onChange={({ pointerType }) => { - if (appState.activeTool.type !== value) { + if (app.state.activeTool.type !== value) { trackEvent("toolbar", value, "ui"); } if (value === "image") { @@ -873,7 +1233,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 @@ -917,7 +1280,7 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} - {app.defaultSelectionTool !== "lasso" && ( + {app.state.stylesPanelMode === "full" && ( app.setActiveTool({ type: "lasso" })} icon={LassoIcon} @@ -939,16 +1302,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 c39d498249..878960a738 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -80,7 +80,6 @@ import { wrapEvent, updateObject, updateActiveTool, - getShortcutKey, isTransparent, easeToValuesRAF, muteFSAbortError, @@ -103,6 +102,8 @@ import { MQ_MAX_MOBILE, MQ_MIN_TABLET, MQ_MAX_TABLET, + MQ_MAX_HEIGHT_LANDSCAPE, + MQ_MAX_WIDTH_LANDSCAPE, } from "@excalidraw/common"; import { @@ -171,7 +172,7 @@ import { getContainerElement, isValidTextContainer, redrawTextBoundingBox, - shouldShowBoundingBox, + hasBoundingBox, getFrameChildren, isCursorInFrame, addElementsToFrame, @@ -405,6 +406,8 @@ import { LassoTrail } from "../lasso"; import { EraserTrail } from "../eraser"; +import { getShortcutKey } from "../shortcut"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -434,6 +437,8 @@ import { findShapeByKey } from "./shapes"; import UnlockPopup from "./UnlockPopup"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { RenderInteractiveSceneCallback, ScrollBars, @@ -663,14 +668,9 @@ class App extends React.Component { >(); onRemoveEventListenersEmitter = new Emitter<[]>(); - defaultSelectionTool: "selection" | "lasso" = "selection"; - constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); - this.defaultSelectionTool = isMobileOrTablet() - ? ("lasso" as const) - : ("selection" as const); const { excalidrawAPI, viewModeEnabled = false, @@ -1524,7 +1524,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 } = @@ -1610,6 +1610,7 @@ class App extends React.Component { onPenModeToggle={this.togglePenMode} onHandToolToggle={this.onHandToolToggle} langCode={getLanguage().code} + renderTopLeftUI={renderTopLeftUI} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} showExitZenModeBtn={ @@ -1622,7 +1623,7 @@ class App extends React.Component { !this.state.isLoading && this.state.showWelcomeScreen && this.state.activeTool.type === - this.defaultSelectionTool && + this.state.preferredSelectionTool.type && !this.state.zenModeEnabled && !this.scene.getElementsIncludingDeleted().length } @@ -2367,6 +2368,14 @@ class App extends React.Component { 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, @@ -2381,12 +2390,13 @@ class App extends React.Component { activeTool.type === "selection" ? { ...activeTool, - type: this.defaultSelectionTool, + type: scene.appState.preferredSelectionTool.type, } : scene.appState.activeTool, isLoading: false, toast: this.state.toast, }; + if (initialData?.scrollToContent) { scene.appState = { ...scene.appState, @@ -2422,8 +2432,10 @@ class App extends React.Component { }; private isMobileBreakpoint = (width: number, height: number) => { - const minSide = Math.min(width, height); - return minSide <= MQ_MAX_MOBILE; + return ( + width <= MQ_MAX_MOBILE || + (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) + ); }; private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { @@ -2477,16 +2489,29 @@ 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({ - 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" - : "full", - }); + 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 }; @@ -3284,7 +3309,10 @@ class App extends React.Component { await this.insertClipboardContent(data, filesList, isPlainPaste); - this.setActiveTool({ type: this.defaultSelectionTool }, true); + this.setActiveTool( + { type: this.state.preferredSelectionTool.type }, + true, + ); event?.preventDefault(); }, ); @@ -3430,7 +3458,7 @@ class App extends React.Component { } }, ); - this.setActiveTool({ type: this.defaultSelectionTool }, true); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true); if (opts.fitToContent) { this.scrollToContent(duplicatedElements, { @@ -3642,7 +3670,7 @@ class App extends React.Component { ...updateActiveTool( this.state, prevState.activeTool.locked - ? { type: this.defaultSelectionTool } + ? { type: this.state.preferredSelectionTool.type } : prevState.activeTool, ), locked: !prevState.activeTool.locked, @@ -3984,7 +4012,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) { @@ -4648,7 +4681,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: this.defaultSelectionTool }); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }); } else { this.setActiveTool({ type: "laser" }); } @@ -5231,7 +5264,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) @@ -5493,7 +5526,7 @@ class App extends React.Component { return; } // we should only be able to double click when mode is selection - if (this.state.activeTool.type !== this.defaultSelectionTool) { + if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) { return; } @@ -6134,7 +6167,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( @@ -6486,6 +6525,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 @@ -7250,14 +7293,8 @@ class App extends React.Component { !this.state.selectedLinearElement?.isEditing && !isElbowArrow(selectedElements[0]) && !( - isLineElement(selectedElements[0]) && - LinearElementEditor.getPointIndexUnderCursor( - selectedElements[0], - elementsMap, - this.state.zoom, - pointerDownState.origin.x, - pointerDownState.origin.y, - ) !== -1 + isLinearElement(selectedElements[0]) && + (isMobileOrTablet() || selectedElements[0].points.length === 2) ) && !( this.state.selectedLinearElement && @@ -7690,7 +7727,7 @@ class App extends React.Component { if (!this.state.activeTool.locked) { this.setState({ activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), }); } @@ -9407,7 +9444,7 @@ class App extends React.Component { this.setState((prevState) => ({ newElement: null, activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), selectedElementIds: makeNextSelectedElementIds( { @@ -10024,7 +10061,7 @@ class App extends React.Component { newElement: null, suggestedBindings: [], activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), }); } else { @@ -10254,7 +10291,7 @@ class App extends React.Component { { newElement: null, activeTool: updateActiveTool(this.state, { - type: this.defaultSelectionTool, + type: this.state.preferredSelectionTool.type, }), }, () => { @@ -10459,7 +10496,10 @@ class App extends React.Component { const initialized = await Promise.all( placeholders.map(async (placeholder, i) => { try { - return await this.initializeImage(placeholder, imageFiles[i]); + return await this.initializeImage( + placeholder, + await normalizeFile(imageFiles[i]), + ); } catch (error: any) { this.setState({ errorMessage: error.message || t("errors.imageInsertError"), @@ -10549,16 +10589,44 @@ class App extends React.Component { if (imageFiles.length > 0 && this.isToolSupported("image")) { return this.insertImages(imageFiles, sceneX, sceneY); } - - const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib); - if (libraryJSON && typeof libraryJSON === "string") { + 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 }); } @@ -10687,7 +10755,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 !== this.defaultSelectionTool + this.state.activeTool.type !== this.state.preferredSelectionTool.type ) { return; } @@ -11141,6 +11209,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, @@ -11166,11 +11245,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 0e3768dcc0..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; @@ -157,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 51c7bbd2c5..238960fa0b 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -6,6 +6,7 @@ import { COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_PALETTE, isTransparent, + isWritableElement, } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; @@ -18,7 +19,7 @@ import { useExcalidrawContainer } from "../App"; import { ButtonSeparator } from "../ButtonSeparator"; import { activeEyeDropperAtom } from "../EyeDropper"; import { PropertiesPopover } from "../PropertiesPopover"; -import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; +import { slashIcon, strokeIcon } from "../icons"; import { saveCaretPosition, restoreCaretPosition, @@ -132,7 +133,9 @@ const ColorPickerPopupContent = ({ preventAutoFocusOnTouch={!!appState.editingTextElement} onFocusOutside={(event) => { // refocus due to eye dropper - focusPickerContent(); + if (!isWritableElement(event.target)) { + focusPickerContent(); + } event.preventDefault(); }} onPointerDownOutside={(event) => { @@ -213,6 +216,11 @@ const ColorPickerPopupContent = ({ type={type} elements={elements} updateData={updateData} + showTitle={ + appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile" + } + showHotKey={appState.stylesPanelMode !== "mobile"} > {colorInputJSX} @@ -227,7 +235,7 @@ const ColorPickerTrigger = ({ label, color, type, - compactMode = false, + stylesPanelMode, mode = "background", onToggle, editingTextElement, @@ -235,7 +243,7 @@ const ColorPickerTrigger = ({ color: string | null; label: string; type: ColorPickerType; - compactMode?: boolean; + stylesPanelMode?: AppState["stylesPanelMode"]; mode?: "background" | "stroke"; onToggle: () => void; editingTextElement?: boolean; @@ -260,6 +268,9 @@ const ColorPickerTrigger = ({ "is-transparent": !color || color === "transparent", "has-outline": !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), + "compact-sizing": + stylesPanelMode === "compact" || stylesPanelMode === "mobile", + "mobile-border": stylesPanelMode === "mobile", })} aria-label={label} style={color ? { "--swatch-color": color } : undefined} @@ -272,20 +283,10 @@ const ColorPickerTrigger = ({ onClick={handleClick} >
{!color && slashIcon}
- {compactMode && color && ( -
- {mode === "background" ? ( - - {backgroundIcon} - - ) : ( + {(stylesPanelMode === "compact" || stylesPanelMode === "mobile") && + color && + mode === "stroke" && ( +
{strokeIcon} - )} -
- )} +
+ )} ); }; @@ -313,12 +313,16 @@ export const ColorPicker = ({ topPicks, updateData, appState, - compactMode = false, }: ColorPickerProps) => { const openRef = useRef(appState.openPopup); useEffect(() => { openRef.current = appState.openPopup; }, [appState.openPopup]); + const compactMode = + type !== "canvasBackground" && + (appState.stylesPanelMode === "compact" || + appState.stylesPanelMode === "mobile"); + return (
{ 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.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 891ae49efd..c52286a173 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -106,6 +106,7 @@ export const FontPicker = React.memo( {isOpened && ( - + {app.state.stylesPanelMode === "full" && ( + + )} { const setAppState = useExcalidrawSetAppState(); + const compactStyle = compactMode + ? { + ...MOBILE_ACTION_BUTTON_BG, + width: "2rem", + height: "2rem", + } + : {}; + return (
    @@ -37,6 +49,7 @@ export const FontPickerTrigger = ({ }} 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..13c69cf8fd 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai"; import { getLanguage, t } from "../i18n"; import Collapsible from "./Stats/Collapsible"; -import { useDevice } from "./App"; +import { useDevice, useExcalidrawContainer } from "./App"; import "./IconPicker.scss"; @@ -39,6 +39,7 @@ function Picker({ numberOfOptionsToAlwaysShow?: number; }) { const device = useDevice(); + const { container } = useExcalidrawContainer(); const handleKeyDown = (event: React.KeyboardEvent) => { const pressedOption = options.find( @@ -152,17 +153,16 @@ function Picker({ ); }; + const isMobile = device.editor.isMobile; + return (
    { +export const InlineIcon = ({ + className, + icon, + size = "1em", +}: { + className?: string; + icon: React.ReactNode; + size?: string; +}) => { return ( 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 454c0f64e5..8da02b30b3 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,141 +64,98 @@ 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.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + renderToolbar()} + {appState.scrolledOutside && + !appState.openMenu && + !appState.openSidebar && ( + + )}
    + + + {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 d4437b3858..ccedd87a02 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -40,6 +40,8 @@ export const PropertiesPopover = React.forwardRef< ref, ) => { const device = useDevice(); + const isMobilePortrait = + device.editor.isMobile && !device.viewport.isLandscape; return ( @@ -47,20 +49,14 @@ export const PropertiesPopover = React.forwardRef< ref={ref} className={clsx("focus-visible-none", className)} data-prevent-outside-click - side={ - device.editor.isMobile && !device.viewport.isLandscape - ? "bottom" - : "right" - } - align={ - device.editor.isMobile && !device.viewport.isLandscape - ? "center" - : "start" - } + side={isMobilePortrait ? "bottom" : "right"} + align={isMobilePortrait ? "center" : "start"} alignOffset={-16} sideOffset={20} + collisionBoundary={container ?? undefined} style={{ - zIndex: "var(--zIndex-popup)", + zIndex: "var(--zIndex-ui-styles-popup)", + marginLeft: device.editor.isMobile ? "0.5rem" : undefined, }} onPointerLeave={onPointerLeave} onKeyDown={onKeyDown} 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 && (