Merge remote-tracking branch 'origin/master' into zsviczian-stickynote

This commit is contained in:
zsviczian
2025-11-01 09:37:17 +00:00
113 changed files with 3743 additions and 1371 deletions
-2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services: services:
excalidraw: excalidraw:
build: build:
+9 -1
View File
@@ -119,6 +119,7 @@ import {
LibraryIndexedDBAdapter, LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter, LibraryLocalStorageMigrationAdapter,
LocalData, LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData"; } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -727,6 +728,8 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom); const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback( const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState], [setShareDialogState],
@@ -901,10 +904,15 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="alertalert--warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
</div> </div>
)} )}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && ( {latestShareableLink && (
<ShareableLinkDialog <ShareableLinkDialog
link={latestShareableLink} link={latestShareableLink}
+4 -1
View File
@@ -530,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null; return null;
} }
if (!existingRoomLinkData) { if (existingRoomLinkData) {
// when joining existing room, don't merge it with current scene data
this.excalidrawAPI.resetScene();
} else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => { const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") { if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" }); return newElementWith(element, { status: "pending" });
+17
View File
@@ -27,6 +27,8 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store"); const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager { class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => { await entries(filesStore).then((entries) => {
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try { try {
const _appState = clearAppStateForLocalStorage(appState); const _appState = clearAppStateForLocalStorage(appState);
@@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = (
JSON.stringify(_appState), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
console.error(error); console.error(error);
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, true);
}
} }
}; };
const isQuotaExceededError = (error: any) => {
return error instanceof DOMException && error.name === "QuotaExceededError";
};
type SavingLockTypes = "collaboration"; type SavingLockTypes = "collaboration";
export class LocalData { export class LocalData {
+11 -3
View File
@@ -58,7 +58,7 @@
} }
} }
.collab-offline-warning { .alert {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 6.5rem; top: 6.5rem;
@@ -69,10 +69,18 @@
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6; z-index: 6;
white-space: pre; white-space: pre;
&--warning {
background-color: var(--color-warning);
color: var(--color-text-warning);
}
&--danger {
background-color: var(--color-danger-dark);
color: var(--color-danger-text);
}
} }
} }
+24 -19
View File
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
sinkDown(idx: number) { sinkDown(idx: number) {
const node = this.content[idx]; const node = this.content[idx];
const nodeScore = this.scoreFunction(node);
while (idx > 0) { while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1; const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN]; const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) { if (nodeScore < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent; this.content[idx] = parent;
idx = parentN; // TODO: Optimize idx = parentN; // TODO: Optimize
} else { } else {
break; break;
} }
} }
this.content[idx] = node;
} }
bubbleUp(idx: number) { bubbleUp(idx: number) {
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node); const score = this.scoreFunction(node);
while (true) { while (true) {
const child2N = (idx + 1) << 1; const child1N = ((idx + 1) << 1) - 1;
const child1N = child2N - 1; const child2N = child1N + 1;
let swap = null; let smallestIdx = idx;
let child1Score = 0; let smallestScore = score;
// Check left child
if (child1N < length) { if (child1N < length) {
const child1 = this.content[child1N]; const child1Score = this.scoreFunction(this.content[child1N]);
child1Score = this.scoreFunction(child1); if (child1Score < smallestScore) {
if (child1Score < score) { smallestIdx = child1N;
swap = child1N; smallestScore = child1Score;
} }
} }
// Check right child
if (child2N < length) { if (child2N < length) {
const child2 = this.content[child2N]; const child2Score = this.scoreFunction(this.content[child2N]);
const child2Score = this.scoreFunction(child2); if (child2Score < smallestScore) {
if (child2Score < (swap === null ? score : child1Score)) { smallestIdx = child2N;
swap = child2N;
} }
} }
if (swap !== null) { if (smallestIdx === idx) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break; break;
} }
// Move the smaller child up, continue finding position for node
this.content[idx] = this.content[smallestIdx];
idx = smallestIdx;
} }
// Place node in its final position
this.content[idx] = node;
} }
push(node: T) { push(node: T) {
+12
View File
@@ -125,6 +125,7 @@ export const ENV = {
}; };
export const CLASSES = { export const CLASSES = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
@@ -266,7 +267,10 @@ export const STRING_MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
} as const; } as const;
export const MIME_TYPES = { export const MIME_TYPES = {
@@ -351,6 +355,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// mobile: up to 699px // mobile: up to 699px
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets // tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops) 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 LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35; 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;
+7 -15
View File
@@ -20,7 +20,6 @@ import {
ENV, ENV,
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isDarwin,
isAndroid, isAndroid,
isIOS, isIOS,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
@@ -93,7 +92,8 @@ export const isWritableElement = (
(target instanceof HTMLInputElement && (target instanceof HTMLInputElement &&
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password")); target.type === "password" ||
target.type === "search"));
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -121,6 +121,11 @@ export const getFontString = ({
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
}; };
/** executes callback in the frame that's after the current one */
export const nextAnimationFrame = async (cb: () => any) => {
requestAnimationFrame(() => requestAnimationFrame(cb));
};
export const debounce = <T extends any[]>( export const debounce = <T extends any[]>(
fn: (...args: T) => void, fn: (...args: T) => void,
timeout: number, timeout: number,
@@ -420,19 +425,6 @@ export const allowFullScreen = () =>
export const exitFullScreen = () => document.exitFullscreen(); export const exitFullScreen = () => document.exitFullscreen();
export const getShortcutKey = (shortcut: string): string => {
shortcut = shortcut
.replace(/\bAlt\b/i, "Alt")
.replace(/\bShift\b/i, "Shift")
.replace(/\b(Enter|Return)\b/i, "Enter");
if (isDarwin) {
return shortcut
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
.replace(/\bAlt\b/i, "Option");
}
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
};
export const viewportCoordsToSceneCoords = ( export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
{ {
+23
View File
@@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = (
intersector, intersector,
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? center[0] : snapPoint[0],
isHorizontal ? center[1] : snapPoint[1],
);
const anotherIntersector = lineSegment(
anotherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
anotherPoint,
),
);
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
anotherIntersector,
FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0];
}
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
+36 -12
View File
@@ -42,6 +42,7 @@ import {
isBoundToContainer, isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@@ -321,19 +322,42 @@ export const getElementLineSegments = (
if (shape.type === "polycurve") { if (shape.type === "polycurve") {
const curves = shape.data; const curves = shape.data;
const points = curves const pointsOnCurves = curves.map((curve) =>
.map((curve) => pointsOnBezierCurves(curve, 10)) pointsOnBezierCurves(curve, 10),
.flat(); );
let i = 0;
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push( if (
lineSegment( (isLineElement(element) && !element.polygon) ||
pointFrom(points[i][0], points[i][1]), isArrowElement(element)
pointFrom(points[i + 1][0], points[i + 1][1]), ) {
), for (const points of pointsOnCurves) {
); let i = 0;
i++;
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
}
} else {
const points = pointsOnCurves.flat();
let i = 0;
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
} }
return segments; return segments;
+7 -1
View File
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe"; type === "rectangle" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
+3
View File
@@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
// string hash function (using djb2). Not cryptographically secure, use only // string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such. // for versioning and such.
// note: hashes individual code units (not code points),
// but for hashing purposes this is fine as it iterates through every code unit
// (as such, no need to encode to byte string first)
export const hashString = (s: string): number => { export const hashString = (s: string): number => {
let hash: number = 5381; let hash: number = 5381;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
+5 -2
View File
@@ -2,6 +2,7 @@ import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid, isAndroid,
isIOS, isIOS,
isMobileOrTablet,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
@@ -326,7 +327,7 @@ export const getTransformHandles = (
); );
}; };
export const shouldShowBoundingBox = ( export const hasBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
) => { ) => {
@@ -345,5 +346,7 @@ export const shouldShowBoundingBox = (
return true; return true;
} }
return element.points.length > 2; // on mobile/tablet we currently don't show bbox because of resize issues
// (also prob best for simplicity's sake)
return element.points.length > 2 && !isMobileOrTablet();
}; };
+6 -8
View File
@@ -10,6 +10,8 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { LinearElementEditor } from "@excalidraw/element";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import { import {
getTextEditor, getTextEditor,
@@ -413,16 +415,12 @@ describe("element binding", () => {
expect(arrow.endBinding?.elementId).toBe(rectRight.id); expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// Drag arrow off of bound rectangle range // Drag arrow off of bound rectangle range
const handles = getTransformHandles( const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
h.state.zoom, -1,
arrayToMap(h.elements), h.scene.getNonDeletedElementsMap(),
"mouse", );
).se!;
Keyboard.keyDown(KEYS.CTRL_OR_CMD); Keyboard.keyDown(KEYS.CTRL_OR_CMD);
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
mouse.downAt(elX, elY); mouse.downAt(elX, elY);
mouse.moveTo(300, 400); mouse.moveTo(300, 400);
mouse.up(); mouse.up();
+3 -1
View File
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element";
@@ -30,6 +30,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -8,6 +8,7 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
isBoundToContainer,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const someTextElements = selectedElements.some((el) => isTextElement(el)); const someTextElements = selectedElements.some(
(el) => isTextElement(el) && !isBoundToContainer(el),
);
return selectedElements.length > 0 && someTextElements; return selectedElements.length > 0 && someTextElements;
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@@ -234,7 +237,7 @@ export const actionWrapTextInContainer = register({
const containerIds: Mutable<AppState["selectedElementIds"]> = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement)) { if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
const container = newElement({ const container = newElement({
type: "rectangle", type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor, backgroundColor: appState.currentItemBackgroundColor,
+7 -4
View File
@@ -7,7 +7,6 @@ import {
MIN_ZOOM, MIN_ZOOM,
THEME, THEME,
ZOOM_STEP, ZOOM_STEP,
getShortcutKey,
updateActiveTool, updateActiveTool,
CODES, CODES,
KEYS, KEYS,
@@ -46,6 +45,7 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
@@ -122,7 +122,10 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ...appState.activeTool, type: app.defaultSelectionTool } ? {
...appState.activeTool,
type: app.state.preferredSelectionTool.type,
}
: appState.activeTool, : appState.activeTool,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -501,7 +504,7 @@ export const actionToggleEraserTool = register({
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: app.defaultSelectionTool, type: app.state.preferredSelectionTool.type,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -532,7 +535,7 @@ export const actionToggleLassoTool = register({
icon: LassoIcon, icon: LassoIcon,
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => { predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso"; return app.state.preferredSelectionTool.type !== "lasso";
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
@@ -1,4 +1,8 @@
import { KEYS, updateActiveTool } from "@excalidraw/common"; import {
KEYS,
MOBILE_ACTION_BUTTON_BG,
updateActiveTool,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element"; import { fixBindingsAfterDeletion } from "@excalidraw/element";
@@ -299,7 +303,7 @@ export const actionDeleteSelected = register({
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool, type: app.state.preferredSelectionTool.type,
}), }),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
@@ -323,7 +327,15 @@ export const actionDeleteSelected = register({
title={t("labels.delete")} title={t("labels.delete")}
aria-label={t("labels.delete")} aria-label={t("labels.delete")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(appState.stylesPanelMode === "mobile" &&
appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
), ),
}); });
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
@@ -26,6 +26,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@@ -1,8 +1,8 @@
import { import {
DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE,
KEYS, KEYS,
MOBILE_ACTION_BUTTON_BG,
arrayToMap, arrayToMap,
getShortcutKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@@ -25,6 +25,7 @@ import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
@@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({
)}`} )}`}
aria-label={t("labels.duplicateSelection")} aria-label={t("labels.duplicateSelection")}
onClick={() => updateData(null)} onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(appState.stylesPanelMode === "mobile" &&
appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
), ),
}); });
@@ -261,13 +261,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: app.defaultSelectionTool, type: app.state.preferredSelectionTool.type,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: app.defaultSelectionTool, type: app.state.preferredSelectionTool.type,
}); });
} }
+3 -1
View File
@@ -14,7 +14,7 @@ import {
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@@ -43,6 +43,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
+19 -3
View File
@@ -1,4 +1,10 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; import {
isWindows,
KEYS,
matchKey,
arrayToMap,
MOBILE_ACTION_BUTTON_BG,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -67,7 +73,7 @@ export const createUndoAction: ActionCreator = (history) => ({
), ),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -85,6 +91,11 @@ export const createUndoAction: ActionCreator = (history) => ({
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isUndoStackEmpty} disabled={isUndoStackEmpty}
data-testid="button-undo" data-testid="button-undo"
style={{
...(appState.stylesPanelMode === "mobile"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
); );
}, },
@@ -103,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -121,6 +132,11 @@ export const createRedoAction: ActionCreator = (history) => ({
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isRedoStackEmpty} disabled={isRedoStackEmpty}
data-testid="button-redo" data-testid="button-redo"
style={{
...(appState.stylesPanelMode === "mobile"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/> />
); );
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
import { isEmbeddableElement } from "@excalidraw/element"; import { isEmbeddableElement } from "@excalidraw/element";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons"; import { LinkIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
+3 -55
View File
@@ -1,65 +1,11 @@
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { HelpIconThin } from "../components/icons";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
label: "buttons.menu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
type="button"
icon={HamburgerMenuIcon}
aria-label={t("buttons.menu")}
onClick={updateData}
selected={appState.openMenu === "canvas"}
/>
),
});
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
label: "buttons.edit",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
visible={showSelectedShapeActions(
appState,
getNonDeletedElements(elements),
)}
type="button"
icon={palette}
aria-label={t("buttons.edit")}
onClick={updateData}
selected={appState.openMenu === "shape"}
/>
),
});
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
label: "welcomeScreen.defaults.helpHint", label: "welcomeScreen.defaults.helpHint",
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
: { : {
name: "help", name: "help",
}, },
openMenu: null,
openPopup: null,
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
@@ -17,7 +17,6 @@ import {
randomInteger, randomInteger,
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
@@ -149,6 +148,8 @@ import {
restoreCaretPosition, restoreCaretPosition,
} from "../hooks/useTextEditorFocus"; } from "../hooks/useTextEditorFocus";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -355,7 +356,10 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"} compactMode={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
/> />
</> </>
), ),
@@ -435,7 +439,10 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} 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 }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.strokeWidth")}</legend>
<legend>{t("labels.strokeWidth")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="stroke-width" group="stroke-width"
@@ -597,9 +602,7 @@ export const actionChangeSloppiness = register({
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.sloppiness")}</legend>
<legend>{t("labels.sloppiness")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="sloppiness" group="sloppiness"
@@ -652,9 +655,7 @@ export const actionChangeStrokeStyle = register({
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.strokeStyle")}</legend>
<legend>{t("labels.strokeStyle")}</legend>
)}
<div className="buttonList"> <div className="buttonList">
<RadioSelection <RadioSelection
group="strokeStyle" group="strokeStyle"
@@ -783,7 +784,8 @@ export const actionChangeFontSize = register({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@@ -1047,7 +1049,7 @@ export const actionChangeFontFamily = register({
return result; return result;
}, },
PanelComponent: ({ elements, appState, app, updateData, data }) => { PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<ElementsMap>(new Map()); const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null); const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them // 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 ( return (
<fieldset> <>
{appState.stylesPanelMode === "full" && ( {appState.stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
)} )}
@@ -1132,7 +1134,7 @@ export const actionChangeFontFamily = register({
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode === "compact"} compactMode={appState.stylesPanelMode !== "full"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => { () => {
@@ -1144,7 +1146,8 @@ export const actionChangeFontFamily = register({
// defensive clear so immediate close won't abuse the cached elements // defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
}, },
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
); );
}} }}
@@ -1220,7 +1223,8 @@ export const actionChangeFontFamily = register({
// Refocus text editor when font picker closes if we were editing text // Refocus text editor when font picker closes if we were editing text
if ( if (
appState.stylesPanelMode === "compact" && (appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile") &&
appState.editingTextElement appState.editingTextElement
) { ) {
restoreCaretPosition(null); // Just refocus without saved position restoreCaretPosition(null); // Just refocus without saved position
@@ -1228,7 +1232,7 @@ export const actionChangeFontFamily = register({
} }
}} }}
/> />
</fieldset> </>
); );
}, },
}); });
@@ -1321,7 +1325,8 @@ export const actionChangeTextAlign = register({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@@ -1420,7 +1425,8 @@ export const actionChangeVerticalAlign = register({
onChange={(value) => { onChange={(value) => {
withCaretPositionPreservation( withCaretPositionPreservation(
() => updateData(value), () => updateData(value),
appState.stylesPanelMode === "compact", appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile",
!!appState.editingTextElement, !!appState.editingTextElement,
data?.onPreventClose, data?.onPreventClose,
); );
@@ -1834,8 +1840,8 @@ export const actionChangeArrowProperties = register({
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => { PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return ( return (
<div className="selected-shape-actions"> <div className="selected-shape-actions">
{renderAction("changeArrowType")}
{renderAction("changeArrowhead")} {renderAction("changeArrowhead")}
{renderAction("changeArrowType")}
</div> </div>
); );
}, },
+2 -1
View File
@@ -1,4 +1,4 @@
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common"; import { KEYS, CODES, isDarwin } from "@excalidraw/common";
import { import {
moveOneLeft, moveOneLeft,
@@ -16,6 +16,7 @@ import {
SendToBackIcon, SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
+1 -5
View File
@@ -45,11 +45,7 @@ export {
} from "./actionExport"; } from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { export { actionShortcuts } from "./actionMenu";
actionToggleCanvasMenu,
actionToggleEditMenu,
actionShortcuts,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";
+2 -1
View File
@@ -1,8 +1,9 @@
import { isDarwin, getShortcutKey } from "@excalidraw/common"; import { isDarwin } from "@excalidraw/common";
import type { SubtypeOf } from "@excalidraw/common/utility-types"; import type { SubtypeOf } from "@excalidraw/common/utility-types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import type { ActionName } from "./types"; import type { ActionName } from "./types";
-2
View File
@@ -73,8 +73,6 @@ export type ActionName =
| "changeArrowProperties" | "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu"
| "toggleEditMenu"
| "undo" | "undo"
| "redo" | "redo"
| "finalize" | "finalize"
+6 -1
View File
@@ -56,6 +56,10 @@ export const getDefaultAppState = (): Omit<
fromSelection: false, fromSelection: false,
lastActiveTool: null, lastActiveTool: null,
}, },
preferredSelectionTool: {
type: "selection",
initialized: false,
},
penMode: false, penMode: false,
penDetected: false, penDetected: false,
errorMessage: null, errorMessage: null,
@@ -178,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
editingTextElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
preferredSelectionTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
@@ -250,7 +255,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true }, lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false }, activeLockedId: { browser: false, export: false, server: false },
stylesPanelMode: { browser: true, export: false, server: false }, stylesPanelMode: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+3 -2
View File
@@ -470,13 +470,14 @@ export const parseDataTransferEvent = async (
Array.from(items || []).map( Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => { async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") { if (item.kind === "file") {
const file = item.getAsFile(); let file = item.getAsFile();
if (file) { if (file) {
const fileHandle = await getFileHandle(item); const fileHandle = await getFileHandle(item);
file = await normalizeFile(file);
return { return {
type: file.type, type: file.type,
kind: "file", kind: "file",
file: await normalizeFile(file), file,
fileHandle, fileHandle,
}; };
} }
+34 -36
View File
@@ -106,15 +106,15 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 2.5rem; min-height: 2.5rem;
pointer-events: auto;
--default-button-size: 2rem; --default-button-size: 2rem;
.compact-action-button { .compact-action-button {
width: 2rem; width: var(--mobile-action-button-size);
height: 2rem; height: var(--mobile-action-button-size);
border: none; border: none;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface); color: var(--color-on-surface);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -122,24 +122,20 @@
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: all 0.2s ease;
background: var(--mobile-action-button-bg);
svg { svg {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
flex: 0 0 auto; flex: 0 0 auto;
} }
&:hover { &.active {
background: var(--button-hover-bg, var(--island-bg-color)); background: var(
border-color: var( --color-surface-primary-container,
--button-hover-border, var(--mobile-action-button-bg)
var(--button-border, var(--default-border-color))
); );
} }
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
} }
.compact-popover-content { .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 { .compact-shape-actions-island {
@@ -174,29 +183,18 @@
overflow-x: hidden; overflow-x: hidden;
} }
.compact-popover-content { .mobile-shape-actions {
.popover-section { z-index: 999;
margin-bottom: 1rem; display: flex;
flex-direction: row;
&:last-child { justify-content: space-between;
margin-bottom: 0; width: 100%;
} background: transparent;
border-radius: var(--border-radius-lg);
.popover-section-title { box-shadow: none;
font-size: 0.75rem; overflow: none;
font-weight: 600; scrollbar-width: none;
color: var(--color-text-secondary); -ms-overflow-style: none;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
} }
.shape-actions-theme-scope { .shape-actions-theme-scope {
File diff suppressed because it is too large Load Diff
+132 -57
View File
@@ -80,7 +80,6 @@ import {
wrapEvent, wrapEvent,
updateObject, updateObject,
updateActiveTool, updateActiveTool,
getShortcutKey,
isTransparent, isTransparent,
easeToValuesRAF, easeToValuesRAF,
muteFSAbortError, muteFSAbortError,
@@ -103,6 +102,8 @@ import {
MQ_MAX_MOBILE, MQ_MAX_MOBILE,
MQ_MIN_TABLET, MQ_MIN_TABLET,
MQ_MAX_TABLET, MQ_MAX_TABLET,
MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -171,7 +172,7 @@ import {
getContainerElement, getContainerElement,
isValidTextContainer, isValidTextContainer,
redrawTextBoundingBox, redrawTextBoundingBox,
shouldShowBoundingBox, hasBoundingBox,
getFrameChildren, getFrameChildren,
isCursorInFrame, isCursorInFrame,
addElementsToFrame, addElementsToFrame,
@@ -405,6 +406,8 @@ import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser"; import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
import ConvertElementTypePopup, { import ConvertElementTypePopup, {
getConversionTypeFromElements, getConversionTypeFromElements,
convertElementTypePopupAtom, convertElementTypePopupAtom,
@@ -434,6 +437,8 @@ import { findShapeByKey } from "./shapes";
import UnlockPopup from "./UnlockPopup"; import UnlockPopup from "./UnlockPopup";
import type { ExcalidrawLibraryIds } from "../data/types";
import type { import type {
RenderInteractiveSceneCallback, RenderInteractiveSceneCallback,
ScrollBars, ScrollBars,
@@ -663,14 +668,9 @@ class App extends React.Component<AppProps, AppState> {
>(); >();
onRemoveEventListenersEmitter = new Emitter<[]>(); onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const { const {
excalidrawAPI, excalidrawAPI,
viewModeEnabled = false, viewModeEnabled = false,
@@ -1524,7 +1524,7 @@ class App extends React.Component<AppProps, AppState> {
public render() { public render() {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
const { renderTopRightUI, renderCustomStats } = this.props; const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
const sceneNonce = this.scene.getSceneNonce(); const sceneNonce = this.scene.getSceneNonce();
const { elementsMap, visibleElements } = const { elementsMap, visibleElements } =
@@ -1610,6 +1610,7 @@ class App extends React.Component<AppProps, AppState> {
onPenModeToggle={this.togglePenMode} onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle} onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code} langCode={getLanguage().code}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
showExitZenModeBtn={ showExitZenModeBtn={
@@ -1622,7 +1623,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.isLoading && !this.state.isLoading &&
this.state.showWelcomeScreen && this.state.showWelcomeScreen &&
this.state.activeTool.type === this.state.activeTool.type ===
this.defaultSelectionTool && this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled && !this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length !this.scene.getElementsIncludingDeleted().length
} }
@@ -2367,6 +2368,14 @@ class App extends React.Component<AppProps, AppState> {
deleteInvisibleElements: true, deleteInvisibleElements: true,
}); });
const activeTool = scene.appState.activeTool; 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 = {
...scene.appState, ...scene.appState,
theme: this.props.theme || scene.appState.theme, theme: this.props.theme || scene.appState.theme,
@@ -2381,12 +2390,13 @@ class App extends React.Component<AppProps, AppState> {
activeTool.type === "selection" activeTool.type === "selection"
? { ? {
...activeTool, ...activeTool,
type: this.defaultSelectionTool, type: scene.appState.preferredSelectionTool.type,
} }
: scene.appState.activeTool, : scene.appState.activeTool,
isLoading: false, isLoading: false,
toast: this.state.toast, toast: this.state.toast,
}; };
if (initialData?.scrollToContent) { if (initialData?.scrollToContent) {
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
@@ -2422,8 +2432,10 @@ class App extends React.Component<AppProps, AppState> {
}; };
private isMobileBreakpoint = (width: number, height: number) => { private isMobileBreakpoint = (width: number, height: number) => {
const minSide = Math.min(width, height); return (
return minSide <= MQ_MAX_MOBILE; width <= MQ_MAX_MOBILE ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
);
}; };
private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => {
@@ -2477,16 +2489,29 @@ class App extends React.Component<AppProps, AppState> {
canFitSidebar: editorWidth > sidebarBreakpoint, 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 // also check if we need to update the app state
this.setState({ this.setState((prevState) => ({
stylesPanelMode: stylesPanelMode,
// NOTE: we could also remove the isMobileOrTablet check here and // reset to box selection mode if the UI changes to full
// always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP) // where you'd not be able to change the mode yourself currently
// but not too narrow (> MQ_MAX_WIDTH_MOBILE) preferredSelectionTool:
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet() stylesPanelMode === "full"
? "compact" ? {
: "full", type: "selection",
}); initialized: true,
}
: prevState.preferredSelectionTool,
}));
if (prevEditorState !== nextEditorState) { if (prevEditorState !== nextEditorState) {
this.device = { ...this.device, editor: nextEditorState }; this.device = { ...this.device, editor: nextEditorState };
@@ -3284,7 +3309,10 @@ class App extends React.Component<AppProps, AppState> {
await this.insertClipboardContent(data, filesList, isPlainPaste); await this.insertClipboardContent(data, filesList, isPlainPaste);
this.setActiveTool({ type: this.defaultSelectionTool }, true); this.setActiveTool(
{ type: this.state.preferredSelectionTool.type },
true,
);
event?.preventDefault(); event?.preventDefault();
}, },
); );
@@ -3430,7 +3458,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}, },
); );
this.setActiveTool({ type: this.defaultSelectionTool }, true); this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true);
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, { this.scrollToContent(duplicatedElements, {
@@ -3642,7 +3670,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool( ...updateActiveTool(
this.state, this.state,
prevState.activeTool.locked prevState.activeTool.locked
? { type: this.defaultSelectionTool } ? { type: this.state.preferredSelectionTool.type }
: prevState.activeTool, : prevState.activeTool,
), ),
locked: !prevState.activeTool.locked, locked: !prevState.activeTool.locked,
@@ -3984,7 +4012,12 @@ class App extends React.Component<AppProps, AppState> {
} }
if (appState) { 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<AppState, K> | null);
} }
if (elements) { if (elements) {
@@ -4648,7 +4681,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") { if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: this.defaultSelectionTool }); this.setActiveTool({ type: this.state.preferredSelectionTool.type });
} else { } else {
this.setActiveTool({ type: "laser" }); this.setActiveTool({ type: "laser" });
} }
@@ -5231,7 +5264,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
considerBoundingBox && considerBoundingBox &&
this.state.selectedElementIds[element.id] && this.state.selectedElementIds[element.id] &&
shouldShowBoundingBox([element], this.state) hasBoundingBox([element], this.state)
) { ) {
// if hitting the bounding box, return early // if hitting the bounding box, return early
// but if not, we should check for other cases as well (e.g. frame name) // but if not, we should check for other cases as well (e.g. frame name)
@@ -5493,7 +5526,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// we should only be able to double click when mode is selection // 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; return;
} }
@@ -6134,7 +6167,13 @@ class App extends React.Component<AppProps, AppState> {
(!this.state.selectedLinearElement || (!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1) && this.state.selectedLinearElement.hoverPointIndex === -1) &&
this.state.openDialog?.name !== "elementLinkSelector" && 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 = const elementWithTransformHandleType =
getElementWithTransformHandleType( getElementWithTransformHandleType(
@@ -6486,6 +6525,10 @@ class App extends React.Component<AppProps, AppState> {
this.setAppState({ snapLines: [] }); this.setAppState({ snapLines: [] });
} }
if (this.state.openPopup) {
this.setState({ openPopup: null });
}
this.updateGestureOnPointerDown(event); this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs // if dragging element is freedraw and another pointerdown event occurs
@@ -7250,14 +7293,8 @@ class App extends React.Component<AppProps, AppState> {
!this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement?.isEditing &&
!isElbowArrow(selectedElements[0]) && !isElbowArrow(selectedElements[0]) &&
!( !(
isLineElement(selectedElements[0]) && isLinearElement(selectedElements[0]) &&
LinearElementEditor.getPointIndexUnderCursor( (isMobileOrTablet() || selectedElements[0].points.length === 2)
selectedElements[0],
elementsMap,
this.state.zoom,
pointerDownState.origin.x,
pointerDownState.origin.y,
) !== -1
) && ) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
@@ -7690,7 +7727,7 @@ class App extends React.Component<AppProps, AppState> {
if (!this.state.activeTool.locked) { if (!this.state.activeTool.locked) {
this.setState({ this.setState({
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool, type: this.state.preferredSelectionTool.type,
}), }),
}); });
} }
@@ -9407,7 +9444,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({ this.setState((prevState) => ({
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool, type: this.state.preferredSelectionTool.type,
}), }),
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ {
@@ -10024,7 +10061,7 @@ class App extends React.Component<AppProps, AppState> {
newElement: null, newElement: null,
suggestedBindings: [], suggestedBindings: [],
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool, type: this.state.preferredSelectionTool.type,
}), }),
}); });
} else { } else {
@@ -10254,7 +10291,7 @@ class App extends React.Component<AppProps, AppState> {
{ {
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool, type: this.state.preferredSelectionTool.type,
}), }),
}, },
() => { () => {
@@ -10459,7 +10496,10 @@ class App extends React.Component<AppProps, AppState> {
const initialized = await Promise.all( const initialized = await Promise.all(
placeholders.map(async (placeholder, i) => { placeholders.map(async (placeholder, i) => {
try { try {
return await this.initializeImage(placeholder, imageFiles[i]); return await this.initializeImage(
placeholder,
await normalizeFile(imageFiles[i]),
);
} catch (error: any) { } catch (error: any) {
this.setState({ this.setState({
errorMessage: error.message || t("errors.imageInsertError"), errorMessage: error.message || t("errors.imageInsertError"),
@@ -10549,16 +10589,44 @@ class App extends React.Component<AppProps, AppState> {
if (imageFiles.length > 0 && this.isToolSupported("image")) { if (imageFiles.length > 0 && this.isToolSupported("image")) {
return this.insertImages(imageFiles, sceneX, sceneY); return this.insertImages(imageFiles, sceneX, sceneY);
} }
const excalidrawLibrary_ids = dataTransferList.getData(
const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib); MIME_TYPES.excalidrawlibIds,
if (libraryJSON && typeof libraryJSON === "string") { );
const excalidrawLibrary_data = dataTransferList.getData(
MIME_TYPES.excalidrawlib,
);
if (excalidrawLibrary_ids || excalidrawLibrary_data) {
try { try {
const libraryItems = parseLibraryJSON(libraryJSON); let libraryItems: LibraryItems | null = null;
this.addElementsFromPasteOrLibrary({ if (excalidrawLibrary_ids) {
elements: distributeLibraryItemsOnSquareGrid(libraryItems), const { itemIds } = JSON.parse(
position: event, excalidrawLibrary_ids,
files: null, ) 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) { } catch (error: any) {
this.setState({ errorMessage: error.message }); this.setState({ errorMessage: error.message });
} }
@@ -10687,7 +10755,7 @@ class App extends React.Component<AppProps, AppState> {
event.nativeEvent.pointerType === "pen" && event.nativeEvent.pointerType === "pen" &&
// always allow if user uses a pen secondary button // always allow if user uses a pen secondary button
event.button !== POINTER_BUTTON.SECONDARY)) && event.button !== POINTER_BUTTON.SECONDARY)) &&
this.state.activeTool.type !== this.defaultSelectionTool this.state.activeTool.type !== this.state.preferredSelectionTool.type
) { ) {
return; return;
} }
@@ -11141,6 +11209,17 @@ class App extends React.Component<AppProps, AppState> {
return [actionCopy, ...options]; return [actionCopy, ...options];
} }
const zIndexActions: ContextMenuItems =
this.state.stylesPanelMode === "full"
? [
CONTEXT_MENU_SEPARATOR,
actionSendBackward,
actionBringForward,
actionSendToBack,
actionBringToFront,
]
: [];
return [ return [
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionCut, actionCut,
@@ -11166,11 +11245,7 @@ class App extends React.Component<AppProps, AppState> {
actionUngroup, actionUngroup,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionAddToLibrary, actionAddToLibrary,
CONTEXT_MENU_SEPARATOR, ...zIndexActions,
actionSendBackward,
actionBringForward,
actionSendToBack,
actionBringToFront,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionFlipHorizontal, actionFlipHorizontal,
actionFlipVertical, actionFlipVertical,
@@ -1,8 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useDevice } from "../App"; import { useDevice } from "../App";
@@ -7,6 +7,12 @@
} }
} }
.color-picker__title {
padding: 0 0.5rem;
font-size: 0.875rem;
text-align: left;
}
.color-picker__heading { .color-picker__heading {
padding: 0 0.5rem; padding: 0 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -157,6 +163,15 @@
width: 1.625rem; width: 1.625rem;
height: 1.625rem; height: 1.625rem;
} }
&.compact-sizing {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
}
&.mobile-border {
border: 1px solid var(--mobile-color-border);
}
} }
.color-picker__button__hotkey-label { .color-picker__button__hotkey-label {
@@ -6,6 +6,7 @@ import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isTransparent, isTransparent,
isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
@@ -18,7 +19,7 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { backgroundIcon, slashIcon, strokeIcon } from "../icons"; import { slashIcon, strokeIcon } from "../icons";
import { import {
saveCaretPosition, saveCaretPosition,
restoreCaretPosition, restoreCaretPosition,
@@ -132,7 +133,9 @@ const ColorPickerPopupContent = ({
preventAutoFocusOnTouch={!!appState.editingTextElement} preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); if (!isWritableElement(event.target)) {
focusPickerContent();
}
event.preventDefault(); event.preventDefault();
}} }}
onPointerDownOutside={(event) => { onPointerDownOutside={(event) => {
@@ -213,6 +216,11 @@ const ColorPickerPopupContent = ({
type={type} type={type}
elements={elements} elements={elements}
updateData={updateData} updateData={updateData}
showTitle={
appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile"
}
showHotKey={appState.stylesPanelMode !== "mobile"}
> >
{colorInputJSX} {colorInputJSX}
</Picker> </Picker>
@@ -227,7 +235,7 @@ const ColorPickerTrigger = ({
label, label,
color, color,
type, type,
compactMode = false, stylesPanelMode,
mode = "background", mode = "background",
onToggle, onToggle,
editingTextElement, editingTextElement,
@@ -235,7 +243,7 @@ const ColorPickerTrigger = ({
color: string | null; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
compactMode?: boolean; stylesPanelMode?: AppState["stylesPanelMode"];
mode?: "background" | "stroke"; mode?: "background" | "stroke";
onToggle: () => void; onToggle: () => void;
editingTextElement?: boolean; editingTextElement?: boolean;
@@ -260,6 +268,9 @@ const ColorPickerTrigger = ({
"is-transparent": !color || color === "transparent", "is-transparent": !color || color === "transparent",
"has-outline": "has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing":
stylesPanelMode === "compact" || stylesPanelMode === "mobile",
"mobile-border": stylesPanelMode === "mobile",
})} })}
aria-label={label} aria-label={label}
style={color ? { "--swatch-color": color } : undefined} style={color ? { "--swatch-color": color } : undefined}
@@ -272,20 +283,10 @@ const ColorPickerTrigger = ({
onClick={handleClick} onClick={handleClick}
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline">{!color && slashIcon}</div>
{compactMode && color && ( {(stylesPanelMode === "compact" || stylesPanelMode === "mobile") &&
<div className="color-picker__button-background"> color &&
{mode === "background" ? ( mode === "stroke" && (
<span <div className="color-picker__button-background">
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{backgroundIcon}
</span>
) : (
<span <span
style={{ style={{
color: color:
@@ -296,9 +297,8 @@ const ColorPickerTrigger = ({
> >
{strokeIcon} {strokeIcon}
</span> </span>
)} </div>
</div> )}
)}
</Popover.Trigger> </Popover.Trigger>
); );
}; };
@@ -313,12 +313,16 @@ export const ColorPicker = ({
topPicks, topPicks,
updateData, updateData,
appState, appState,
compactMode = false,
}: ColorPickerProps) => { }: ColorPickerProps) => {
const openRef = useRef(appState.openPopup); const openRef = useRef(appState.openPopup);
useEffect(() => { useEffect(() => {
openRef.current = appState.openPopup; openRef.current = appState.openPopup;
}, [appState.openPopup]); }, [appState.openPopup]);
const compactMode =
type !== "canvasBackground" &&
(appState.stylesPanelMode === "compact" ||
appState.stylesPanelMode === "mobile");
return ( return (
<div> <div>
<div <div
@@ -350,7 +354,7 @@ export const ColorPicker = ({
color={color} color={color}
label={label} label={label}
type={type} type={type}
compactMode={compactMode} stylesPanelMode={appState.stylesPanelMode}
mode={type === "elementStroke" ? "stroke" : "background"} mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement} editingTextElement={!!appState.editingTextElement}
onToggle={() => { onToggle={() => {
@@ -37,8 +37,10 @@ interface PickerProps {
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
children?: React.ReactNode; children?: React.ReactNode;
showTitle?: boolean;
onEyeDropperToggle: (force?: boolean) => void; onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
} }
export const Picker = React.forwardRef( export const Picker = React.forwardRef(
@@ -51,11 +53,21 @@ export const Picker = React.forwardRef(
palette, palette,
updateData, updateData,
children, children,
showTitle,
onEyeDropperToggle, onEyeDropperToggle,
onEscape, onEscape,
showHotKey = true,
}: PickerProps, }: PickerProps,
ref, ref,
) => { ) => {
const title = showTitle
? type === "elementStroke"
? t("labels.stroke")
: type === "elementBackground"
? t("labels.background")
: null
: null;
const [customColors] = React.useState(() => { const [customColors] = React.useState(() => {
if (type === "canvasBackground") { if (type === "canvasBackground") {
return []; return [];
@@ -154,6 +166,8 @@ export const Picker = React.forwardRef(
// to allow focusing by clicking but not by tabbing // to allow focusing by clicking but not by tabbing
tabIndex={-1} tabIndex={-1}
> >
{title && <div className="color-picker__title">{title}</div>}
{!!customColors.length && ( {!!customColors.length && (
<div> <div>
<PickerHeading> <PickerHeading>
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef(
palette={palette} palette={palette}
onChange={onChange} onChange={onChange}
activeShade={activeShade} activeShade={activeShade}
showHotKey={showHotKey}
/> />
</div> </div>
<div> <div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading> <PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList color={color} onChange={onChange} palette={palette} /> <ShadeList
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div> </div>
{children} {children}
</div> </div>
@@ -20,6 +20,7 @@ interface PickerColorListProps {
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
activeShade: number; activeShade: number;
showHotKey?: boolean;
} }
const PickerColorList = ({ const PickerColorList = ({
@@ -27,6 +28,7 @@ const PickerColorList = ({
color, color,
onChange, onChange,
activeShade, activeShade,
showHotKey = true,
}: PickerColorListProps) => { }: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color, color,
@@ -82,7 +84,7 @@ const PickerColorList = ({
key={key} key={key}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} /> {showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
</button> </button>
); );
})} })}
@@ -16,9 +16,15 @@ interface ShadeListProps {
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
showHotKey?: boolean;
} }
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { export const ShadeList = ({
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent", color: color || "transparent",
palette, palette,
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
}} }}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade /> {showHotKey && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button> </button>
))} ))}
</div> </div>
@@ -100,6 +100,19 @@ $verticalBreakpoint: 861px;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
cursor: pointer; cursor: pointer;
--icon-size: 1rem;
&.command-item-large {
height: 2.75rem;
--icon-size: 1.5rem;
.icon {
width: var(--icon-size);
height: var(--icon-size);
margin-right: 0.625rem;
}
}
&:active { &:active {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
} }
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
} }
.icon { .icon {
width: 16px; width: var(--icon-size, 1rem);
height: 16px; height: var(--icon-size, 1rem);
margin-right: 6px; margin-right: 0.375rem;
.library-item-icon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
} }
} }
} }
@@ -1,18 +1,19 @@
import clsx from "clsx"; import clsx from "clsx";
import fuzzy from "fuzzy"; import fuzzy from "fuzzy";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useMemo, useState } from "react";
import { import {
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
EVENT, EVENT,
KEYS, KEYS,
capitalizeString, capitalizeString,
getShortcutKey,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch"; import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types"; import type { MarkRequired } from "@excalidraw/common/utility-types";
import { import {
@@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify"; import { Ellipsify } from "../Ellipsify";
import * as defaultItems from "./defaultCommandPaletteItems"; import {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
} from "../../data/library";
import {
useLibraryCache,
useLibraryItemSvg,
} from "../../hooks/useLibraryItemSvg";
import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss"; import "./CommandPalette.scss";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
import type { AppProps, AppState, UIAppState } from "../../types"; import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types";
import type { ShortcutName } from "../../actions/shortcuts"; import type { ShortcutName } from "../../actions/shortcuts";
import type { TranslationKeys } from "../../i18n"; import type { TranslationKeys } from "../../i18n";
import type { Action } from "../../actions/types"; import type { Action } from "../../actions/types";
@@ -80,6 +90,7 @@ export const DEFAULT_CATEGORIES = {
editor: "Editor", editor: "Editor",
elements: "Elements", elements: "Elements",
links: "Links", links: "Links",
library: "Library",
}; };
const getCategoryOrder = (category: string) => { const getCategoryOrder = (category: string) => {
@@ -207,6 +218,34 @@ function CommandPaletteInner({
appProps, appProps,
}); });
const [libraryItemsData] = useAtom(libraryItemsAtom);
const libraryCommands: CommandPaletteItem[] = useMemo(() => {
return (
libraryItemsData.libraryItems
?.filter(
(libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
!!libraryItem.name,
)
.map((libraryItem) => ({
label: libraryItem.name,
icon: (
<LibraryItemIcon
id={libraryItem.id}
elements={libraryItem.elements}
/>
),
category: "Library",
order: getCategoryOrder("Library"),
haystack: deburr(libraryItem.name),
perform: () => {
app.onInsertElements(
distributeLibraryItemsOnSquareGrid([libraryItem]),
);
},
})) || []
);
}, [app, libraryItemsData.libraryItems]);
useEffect(() => { useEffect(() => {
// these props change often and we don't want them to re-run the effect // these props change often and we don't want them to re-run the effect
// which would renew `allCommands`, cascading down and resetting state. // which would renew `allCommands`, cascading down and resetting state.
@@ -438,7 +477,6 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementStroke", openPopup: "elementStroke",
})); }));
}, },
@@ -458,7 +496,6 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementBackground", openPopup: "elementBackground",
})); }));
}, },
@@ -588,8 +625,9 @@ function CommandPaletteInner({
setAllCommands(allCommands); setAllCommands(allCommands);
setLastUsed( setLastUsed(
allCommands.find((command) => command.label === lastUsed?.label) ?? [...allCommands, ...libraryCommands].find(
null, (command) => command.label === lastUsed?.label,
) ?? null,
); );
} }
}, [ }, [
@@ -600,6 +638,7 @@ function CommandPaletteInner({
lastUsed?.label, lastUsed?.label,
setLastUsed, setLastUsed,
setAppState, setAppState,
libraryCommands,
]); ]);
const [commandSearch, setCommandSearch] = useState(""); const [commandSearch, setCommandSearch] = useState("");
@@ -796,9 +835,17 @@ function CommandPaletteInner({
return nextCommandsByCategory; return nextCommandsByCategory;
}; };
let matchingCommands = allCommands let matchingCommands =
.filter(isCommandAvailable) commandSearch?.length > 1
.sort((a, b) => a.order - b.order); ? [
...allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order),
...libraryCommands,
]
: allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order);
const showLastUsed = const showLastUsed =
!commandSearch && lastUsed && isCommandAvailable(lastUsed); !commandSearch && lastUsed && isCommandAvailable(lastUsed);
@@ -822,14 +869,20 @@ function CommandPaletteInner({
); );
matchingCommands = fuzzy matchingCommands = fuzzy
.filter(_query, matchingCommands, { .filter(_query, matchingCommands, {
extract: (command) => command.haystack, extract: (command) => command.haystack ?? "",
}) })
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.map((item) => item.original); .map((item) => item.original);
setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
setCurrentCommand(matchingCommands[0] ?? null); setCurrentCommand(matchingCommands[0] ?? null);
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]); }, [
commandSearch,
allCommands,
isCommandAvailable,
lastUsed,
libraryCommands,
]);
return ( return (
<Dialog <Dialog
@@ -904,6 +957,7 @@ function CommandPaletteInner({
onMouseMove={() => setCurrentCommand(command)} onMouseMove={() => setCurrentCommand(command)}
showShortcut={!app.device.viewport.isMobile} showShortcut={!app.device.viewport.isMobile}
appState={uiAppState} appState={uiAppState}
size={category === "Library" ? "large" : "small"}
/> />
))} ))}
</div> </div>
@@ -919,6 +973,20 @@ function CommandPaletteInner({
</Dialog> </Dialog>
); );
} }
const LibraryItemIcon = ({
id,
elements,
}: {
id: LibraryItem["id"] | null;
elements: LibraryItem["elements"] | undefined;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const { svgCache } = useLibraryCache();
useLibraryItemSvg(id, elements, svgCache, ref);
return <div className="library-item-icon" ref={ref} />;
};
const CommandItem = ({ const CommandItem = ({
command, command,
@@ -928,6 +996,7 @@ const CommandItem = ({
onClick, onClick,
showShortcut, showShortcut,
appState, appState,
size = "small",
}: { }: {
command: CommandPaletteItem; command: CommandPaletteItem;
isSelected: boolean; isSelected: boolean;
@@ -936,6 +1005,7 @@ const CommandItem = ({
onClick: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void;
showShortcut: boolean; showShortcut: boolean;
appState: UIAppState; appState: UIAppState;
size?: "small" | "large";
}) => { }) => {
const noop = () => {}; const noop = () => {};
@@ -944,6 +1014,7 @@ const CommandItem = ({
className={clsx("command-item", { className={clsx("command-item", {
"item-selected": isSelected, "item-selected": isSelected,
"item-disabled": disabled, "item-disabled": disabled,
"command-item-large": size === "large",
})} })}
ref={(ref) => { ref={(ref) => {
if (isSelected && !disabled) { if (isSelected && !disabled) {
@@ -959,6 +1030,8 @@ const CommandItem = ({
<div className="name"> <div className="name">
{command.icon && ( {command.icon && (
<InlineIcon <InlineIcon
className="icon"
size="var(--icon-size, 1rem)"
icon={ icon={
typeof command.icon === "function" typeof command.icon === "function"
? command.icon(appState) ? command.icon(appState)
@@ -1,6 +1,10 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.context-menu-popover {
z-index: var(--zIndex-ui-context-menu);
}
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
@@ -64,6 +64,7 @@ export const ContextMenu = React.memo(
offsetTop={appState.offsetTop} offsetTop={appState.offsetTop}
viewportWidth={appState.width} viewportWidth={appState.width}
viewportHeight={appState.height} viewportHeight={appState.height}
className="context-menu-popover"
> >
<ul <ul
className="context-menu" className="context-menu"
@@ -1,5 +1,8 @@
.excalidraw { .excalidraw {
.ExcalidrawLogo { .ExcalidrawLogo {
--logo-icon--mobile: 1rem;
--logo-text--mobile: 0.75rem;
--logo-icon--xs: 2rem; --logo-icon--xs: 2rem;
--logo-text--xs: 1.5rem; --logo-text--xs: 1.5rem;
@@ -30,6 +33,17 @@
color: var(--color-logo-text); color: var(--color-logo-text);
} }
&.is-mobile {
.ExcalidrawLogo-icon {
height: var(--logo-icon--mobile);
}
.ExcalidrawLogo-text {
height: var(--logo-text--mobile);
margin-left: 0.5rem;
}
}
&.is-xs { &.is-xs {
.ExcalidrawLogo-icon { .ExcalidrawLogo-icon {
height: var(--logo-icon--xs); height: var(--logo-icon--xs);
@@ -41,7 +41,7 @@ const LogoText = () => (
</svg> </svg>
); );
type LogoSize = "xs" | "small" | "normal" | "large" | "custom"; type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
interface LogoProps { interface LogoProps {
size?: LogoSize; size?: LogoSize;
@@ -106,6 +106,7 @@ export const FontPicker = React.memo(
<FontPickerTrigger <FontPickerTrigger
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
isOpened={isOpened} isOpened={isOpened}
compactMode={compactMode}
/> />
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList
@@ -338,11 +338,13 @@ export const FontPickerList = React.memo(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement} preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch {app.state.stylesPanelMode === "full" && (
ref={inputRef} <QuickSearch
placeholder={t("quickSearch.placeholder")} ref={inputRef}
onChange={debounce(setSearchTerm, 20)} placeholder={t("quickSearch.placeholder")}
/> onChange={debounce(setSearchTerm, 20)}
/>
)}
<ScrollableList <ScrollableList
className="dropdown-menu fonts manual-hover" className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")} placeholder={t("fontList.empty")}
@@ -1,5 +1,7 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types"; import type { FontFamilyValues } from "@excalidraw/element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
@@ -11,14 +13,24 @@ import { useExcalidrawSetAppState } from "../App";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean; isOpened?: boolean;
compactMode?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
isOpened = false, isOpened = false,
compactMode = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const compactStyle = compactMode
? {
...MOBILE_ACTION_BUTTON_BG,
width: "2rem",
height: "2rem",
}
: {};
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger"> <div data-openpopup="fontFamily" className="properties-trigger">
@@ -37,6 +49,7 @@ export const FontPickerTrigger = ({
}} }}
style={{ style={{
border: "none", border: "none",
...compactStyle,
}} }}
/> />
</div> </div>
@@ -18,7 +18,7 @@ type LockIconProps = {
export const HandButton = (props: LockIconProps) => { export const HandButton = (props: LockIconProps) => {
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable: false })} className={clsx("Shape", { fillable: false, active: props.checked })}
type="radio" type="radio"
icon={handIcon} icon={handIcon}
name="editor-current-shape" name="editor-current-shape"
@@ -2,11 +2,12 @@ import React from "react";
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common"; import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
@@ -28,11 +28,24 @@ $wide-viewport-width: 1000px;
> span { > span {
padding: 0.25rem; padding: 0.25rem;
} }
kbd {
display: inline-block;
margin: 0 1px;
font-family: monospace;
border: 1px solid var(--color-gray-40);
border-radius: 4px;
padding: 1px 3px;
font-size: 10px;
}
} }
&.theme--dark { &.theme--dark {
.HintViewer { .HintViewer {
color: var(--color-gray-60); color: var(--color-gray-60);
kbd {
border-color: var(--color-gray-60);
}
} }
} }
} }
+93 -31
View File
@@ -9,11 +9,10 @@ import {
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getShortcutKey } from "@excalidraw/common";
import { isNodeInFlowchart } from "@excalidraw/element"; import { isNodeInFlowchart } from "@excalidraw/element";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping"; import { isGridModeEnabled } from "../snapping";
@@ -28,6 +27,11 @@ interface HintViewerProps {
app: AppClassProperties; app: AppClassProperties;
} }
const getTaggedShortcutKey = (key: string | string[]) =>
Array.isArray(key)
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
: `<kbd>${getShortcutKey(key)}</kbd>`;
const getHints = ({ const getHints = ({
appState, appState,
isMobile, isMobile,
@@ -42,7 +46,9 @@ const getHints = ({
appState.openSidebar.tab === CANVAS_SEARCH_TAB && appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.matches.length appState.searchMatches?.matches.length
) { ) {
return t("hints.dismissSearch"); return t("hints.dismissSearch", {
shortcut: getTaggedShortcutKey("Escape"),
});
} }
if (appState.openSidebar && !device.editor.canFitSidebar) { if (appState.openSidebar && !device.editor.canFitSidebar) {
@@ -50,14 +56,21 @@ const getHints = ({
} }
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
return t("hints.eraserRevert"); return t("hints.eraserRevert", {
shortcut: getTaggedShortcutKey("Alt"),
});
} }
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) { if (multiMode) {
return t("hints.linearElementMulti"); return t("hints.linearElementMulti", {
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey("Enter"),
});
} }
if (activeTool.type === "arrow") { if (activeTool.type === "arrow") {
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") }); return t("hints.arrowTool", {
shortcut: getTaggedShortcutKey("A"),
});
} }
return t("hints.linearElement"); return t("hints.linearElement");
} }
@@ -83,31 +96,51 @@ const getHints = ({
) { ) {
const targetElement = selectedElements[0]; const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length === 2) { if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle"); return t("hints.lockAngle", {
shortcut: getTaggedShortcutKey("Shift"),
});
} }
return isImageElement(targetElement) return isImageElement(targetElement)
? t("hints.resizeImage") ? t("hints.resizeImage", {
: t("hints.resize"); shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
})
: t("hints.resize", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
} }
if (isRotating && lastPointerDownWith === "mouse") { if (isRotating && lastPointerDownWith === "mouse") {
return t("hints.rotate"); return t("hints.rotate", {
shortcut: getTaggedShortcutKey("Shift"),
});
} }
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected"); return t("hints.text_selected", {
shortcut: getTaggedShortcutKey("Enter"),
});
} }
if (appState.editingTextElement) { if (appState.editingTextElement) {
return t("hints.text_editing"); return t("hints.text_editing", {
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
} }
if (appState.croppingElementId) { if (appState.croppingElementId) {
return t("hints.leaveCropEditor"); return t("hints.leaveCropEditor", {
shortcut_1: getTaggedShortcutKey("Enter"),
shortcut_2: getTaggedShortcutKey("Escape"),
});
} }
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) { if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor"); return t("hints.enterCropEditor", {
shortcut: getTaggedShortcutKey("Enter"),
});
} }
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
@@ -117,33 +150,57 @@ const getHints = ({
!appState.editingTextElement && !appState.editingTextElement &&
!appState.selectedLinearElement?.isEditing !appState.selectedLinearElement?.isEditing
) { ) {
return [t("hints.deepBoxSelect")]; return t("hints.deepBoxSelect", {
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
} }
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping"); return t("hints.disableSnapping", {
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
} }
if (!selectedElements.length && !isMobile) { if (!selectedElements.length && !isMobile) {
return [t("hints.canvasPanning")]; return t("hints.canvasPanning", {
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
shortcut_2: getTaggedShortcutKey("Space"),
});
} }
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) { if (isLinearElement(selectedElements[0])) {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
return appState.selectedLinearElement.selectedPointsIndices return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected") ? t("hints.lineEditor_pointSelected", {
: t("hints.lineEditor_nothingSelected"); shortcut_1: getTaggedShortcutKey("Delete"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
})
: t("hints.lineEditor_nothingSelected", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
} }
return isLineElement(selectedElements[0]) return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info") ? t("hints.lineEditor_line_info", {
: t("hints.lineEditor_info"); shortcut: getTaggedShortcutKey("Enter"),
})
: t("hints.lineEditor_info", {
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
} }
if ( if (
!appState.newElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0]) isTextBindableContainer(selectedElements[0])
) { ) {
const bindTextToElement = t("hints.bindTextToElement", {
shortcut: getTaggedShortcutKey("Enter"),
});
const createFlowchart = t("hints.createFlowchart", {
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
});
if (isFlowchartNodeElement(selectedElements[0])) { if (isFlowchartNodeElement(selectedElements[0])) {
if ( if (
isNodeInFlowchart( isNodeInFlowchart(
@@ -151,13 +208,13 @@ const getHints = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
) )
) { ) {
return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; return [bindTextToElement, createFlowchart];
} }
return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; return [bindTextToElement, createFlowchart];
} }
return t("hints.bindTextToElement"); return bindTextToElement;
} }
} }
} }
@@ -183,16 +240,21 @@ export const HintViewer = ({
} }
const hint = Array.isArray(hints) const hint = Array.isArray(hints)
? hints ? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
.map((hint) => { : hints;
return getShortcutKey(hint).replace(/\. ?$/, "");
}) const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
.join(". ") if (index % 2 === 1) {
: getShortcutKey(hints); const shortcutMatch =
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
}
return part;
});
return ( return (
<div className="HintViewer"> <div className="HintViewer">
<span>{hint}</span> <span>{hintJSX}</span>
</div> </div>
); );
}; };
@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useDevice } from "./App"; import { useDevice, useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
@@ -39,6 +39,7 @@ function Picker<T>({
numberOfOptionsToAlwaysShow?: number; numberOfOptionsToAlwaysShow?: number;
}) { }) {
const device = useDevice(); const device = useDevice();
const { container } = useExcalidrawContainer();
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = options.find(
@@ -152,17 +153,16 @@ function Picker<T>({
); );
}; };
const isMobile = device.editor.isMobile;
return ( return (
<Popover.Content <Popover.Content
side={ side={isMobile ? "right" : "bottom"}
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start" align="start"
sideOffset={12} sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-popup)" }} style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
> >
<div <div
className={`picker`} className={`picker`}
+13 -3
View File
@@ -1,10 +1,20 @@
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { export const InlineIcon = ({
className,
icon,
size = "1em",
}: {
className?: string;
icon: React.ReactNode;
size?: string;
}) => {
return ( return (
<span <span
className={className}
style={{ style={{
width: "1em", width: size,
height: "100%",
margin: "0 0.5ex 0 0.5ex", margin: "0 0.5ex 0 0.5ex",
display: "inline-block", display: "inline-flex",
lineHeight: 0, lineHeight: 0,
verticalAlign: "middle", verticalAlign: "middle",
flex: "0 0 auto", flex: "0 0 auto",
+4 -4
View File
@@ -91,6 +91,7 @@ interface LayerUIProps {
onPenModeToggle: AppClassProperties["togglePenMode"]; onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
@@ -149,6 +150,7 @@ const LayerUI = ({
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
showExitZenModeBtn, showExitZenModeBtn,
renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
UIOptions, UIOptions,
@@ -366,7 +368,7 @@ const LayerUI = ({
/> />
<ShapesSwitcher <ShapesSwitcher
appState={appState} setAppState={setAppState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions} UIOptions={UIOptions}
app={app} app={app}
@@ -582,13 +584,11 @@ const LayerUI = ({
renderJSONExportDialog={renderJSONExportDialog} renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle} onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars} renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />
@@ -133,15 +133,10 @@
} }
.layer-ui__library .library-menu-dropdown-container { .layer-ui__library .library-menu-dropdown-container {
z-index: 1;
position: relative; position: relative;
&--in-heading { &--in-heading {
padding: 0; margin-left: auto;
position: absolute;
top: 1rem;
right: 0.75rem;
z-index: 1;
.dropdown-menu { .dropdown-menu {
top: 100%; top: 100%;
} }
+47 -1
View File
@@ -11,6 +11,11 @@ import {
LIBRARY_DISABLED_TYPES, LIBRARY_DISABLED_TYPES,
randomId, randomId,
isShallowEqual, isShallowEqual,
KEYS,
isWritableElement,
addEventListener,
EVENT,
CLASSES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -266,11 +271,52 @@ export const LibraryMenu = memo(() => {
const memoizedLibrary = useMemo(() => app.library, [app.library]); const memoizedLibrary = useMemo(() => app.library, [app.library]);
const pendingElements = usePendingElementsMemo(appState, app); const pendingElements = usePendingElementsMemo(appState, app);
useEffect(() => {
return addEventListener(
document,
EVENT.KEYDOWN,
(event) => {
if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) {
const target = event.target;
if (target.closest(`.${CLASSES.SIDEBAR}`)) {
// stop propagation so that we don't prevent it downstream
// (default browser behavior is to clear search input on ESC)
if (selectedItems.length > 0) {
event.stopPropagation();
setSelectedItems([]);
} else if (
isWritableElement(target) &&
target instanceof HTMLInputElement &&
!target.value
) {
event.stopPropagation();
// if search input empty -> close library
// (maybe not a good idea?)
setAppState({ openSidebar: null });
app.focusContainer();
}
} else if (selectedItems.length > 0) {
const { x, y } = app.lastViewportPosition;
const elementUnderCursor = document.elementFromPoint(x, y);
// also deselect elements if sidebar doesn't have focus but the
// cursor is over it
if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) {
event.stopPropagation();
setSelectedItems([]);
}
}
}
},
{ capture: true },
);
}, [selectedItems, setAppState, app]);
const onInsertLibraryItems = useCallback( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
app.focusContainer();
}, },
[onInsertElements], [onInsertElements, app],
); );
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
@@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{
{t("buttons.export")} {t("buttons.export")}
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
{itemsSelected && ( {itemsSelected && (
<DropdownMenu.Item <DropdownMenu.Item
icon={publishIcon} icon={publishIcon}
@@ -237,6 +229,14 @@ export const LibraryDropdownMenuButton: React.FC<{
{t("buttons.publishLibrary")} {t("buttons.publishLibrary")}
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
); );
@@ -1,24 +1,42 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
--container-padding-y: 1.5rem; --container-padding-y: 1rem;
--container-padding-x: 0.75rem; --container-padding-x: 0.75rem;
.library-menu-items-header {
display: flex;
padding-top: 1rem;
padding-bottom: 0.5rem;
gap: 0.5rem;
}
.library-menu-items__no-items { .library-menu-items__no-items {
text-align: center; text-align: center;
color: var(--color-gray-70); color: var(--color-gray-70);
line-height: 1.5; line-height: 1.5;
font-size: 0.875rem; font-size: 0.875rem;
width: 100%; width: 100%;
min-height: 55px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&__label { &__label {
color: var(--color-primary); color: var(--color-primary);
font-weight: 700; font-weight: 700;
font-size: 1.125rem; font-size: 1.125rem;
margin-bottom: 0.75rem; margin-bottom: 0.25rem;
} }
} }
.library-menu-items__no-items__hint {
color: var(--color-border-outline);
padding: 0.75rem 1rem;
}
&.theme--dark { &.theme--dark {
.library-menu-items__no-items { .library-menu-items__no-items {
color: var(--color-gray-40); color: var(--color-gray-40);
@@ -34,7 +52,7 @@
overflow-y: auto; overflow-y: auto;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
justify-content: center; justify-content: flex-start;
margin: 0; margin: 0;
position: relative; position: relative;
@@ -51,26 +69,45 @@
} }
&__items { &__items {
// so that spinner is relative-positioned to this container
position: relative;
row-gap: 0.5rem; row-gap: 0.5rem;
padding: var(--container-padding-y) 0; padding: 1rem 0 var(--container-padding-y) 0;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
margin-bottom: 1rem;
} }
&__header { &__header {
display: flex;
align-items: center;
flex: 1 1 auto;
color: var(--color-primary); color: var(--color-primary);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
width: 100%; width: 100%;
padding-right: 4rem; // due to dropdown button
box-sizing: border-box; box-sizing: border-box;
&--excal { &--excal {
margin-top: 2rem; margin-top: 2rem;
} }
&__hint {
margin-left: auto;
font-size: 10px;
color: var(--color-border-outline);
font-weight: 400;
kbd {
font-family: monospace;
border: 1px solid var(--color-border-outline);
border-radius: 4px;
padding: 1px 3px;
}
}
} }
&__grid { &__grid {
@@ -79,6 +116,24 @@
grid-gap: 1rem; grid-gap: 1rem;
} }
&__search {
flex: 1 1 auto;
margin: 0;
.ExcTextField__input {
height: var(--lg-button-size);
input {
font-size: 0.875rem;
}
}
&.hideCancelButton input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
display: none;
}
}
.separator { .separator {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -6,11 +6,14 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common";
import { duplicateElements } from "@excalidraw/element"; import { duplicateElements } from "@excalidraw/element";
import { serializeLibraryAsJSON } from "../data/json"; import clsx from "clsx";
import { deburr } from "../deburr";
import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import { useScrollPosition } from "../hooks/useScrollPosition"; import { useScrollPosition } from "../hooks/useScrollPosition";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -27,6 +30,14 @@ import Stack from "./Stack";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { TextField } from "./TextField";
import { useDevice } from "./App";
import { Button } from "./Button";
import type { ExcalidrawLibraryIds } from "../data/types";
import type { import type {
ExcalidrawProps, ExcalidrawProps,
LibraryItem, LibraryItem,
@@ -64,6 +75,7 @@ export default function LibraryMenuItems({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) { }) {
const device = useDevice();
const libraryContainerRef = useRef<HTMLDivElement>(null); const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef); const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
@@ -75,6 +87,30 @@ export default function LibraryMenuItems({
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const { svgCache } = useLibraryCache(); const { svgCache } = useLibraryCache();
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const [searchInputValue, setSearchInputValue] = useState("");
const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length;
const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim();
const filteredItems = useMemo(() => {
const searchQuery = deburr(searchInputValue.trim().toLowerCase());
if (!searchQuery) {
return [];
}
return libraryItems.filter((item) => {
const itemName = item.name || "";
return (
itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery)
);
});
}, [libraryItems, searchInputValue]);
const unpublishedItems = useMemo( const unpublishedItems = useMemo(
() => libraryItems.filter((item) => item.status !== "published"), () => libraryItems.filter((item) => item.status !== "published"),
[libraryItems], [libraryItems],
@@ -85,23 +121,10 @@ export default function LibraryMenuItems({
[libraryItems], [libraryItems],
); );
const showBtn = !libraryItems.length && !pendingElements.length;
const isLibraryEmpty =
!pendingElements.length &&
!unpublishedItems.length &&
!publishedItems.length;
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const onItemSelectToggle = useCallback( const onItemSelectToggle = useCallback(
(id: LibraryItem["id"], event: React.MouseEvent) => { (id: LibraryItem["id"], event: React.MouseEvent) => {
const shouldSelect = !selectedItems.includes(id); const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems]; const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) { if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) { if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex( const rangeStart = orderedItems.findIndex(
@@ -115,10 +138,13 @@ export default function LibraryMenuItems({
} }
const selectedItemsMap = arrayToMap(selectedItems); const selectedItemsMap = arrayToMap(selectedItems);
// Support both top-down and bottom-up selection by using min/max
const minRange = Math.min(rangeStart, rangeEnd);
const maxRange = Math.max(rangeStart, rangeEnd);
const nextSelectedIds = orderedItems.reduce( const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => { (acc: LibraryItem["id"][], item, idx) => {
if ( if (
(idx >= rangeStart && idx <= rangeEnd) || (idx >= minRange && idx <= maxRange) ||
selectedItemsMap.has(item.id) selectedItemsMap.has(item.id)
) { ) {
acc.push(item.id); acc.push(item.id);
@@ -127,7 +153,6 @@ export default function LibraryMenuItems({
}, },
[], [],
); );
onSelectItems(nextSelectedIds); onSelectItems(nextSelectedIds);
} else { } else {
onSelectItems([...selectedItems, id]); onSelectItems([...selectedItems, id]);
@@ -147,6 +172,14 @@ export default function LibraryMenuItems({
], ],
); );
useEffect(() => {
// if selection is removed (e.g. via esc), reset last selected item
// so that subsequent shift+clicks don't select a large range
if (!selectedItems.length) {
setLastSelectedItem(null);
}
}, [selectedItems]);
const getInsertedElements = useCallback( const getInsertedElements = useCallback(
(id: string) => { (id: string) => {
let targetElements; let targetElements;
@@ -175,12 +208,17 @@ export default function LibraryMenuItems({
const onItemDrag = useCallback( const onItemDrag = useCallback(
(id: LibraryItem["id"], event: React.DragEvent) => { (id: LibraryItem["id"], event: React.DragEvent) => {
// we want to serialize just the ids so the operation is fast and there's
// no race condition if people drop the library items on canvas too fast
const data: ExcalidrawLibraryIds = {
itemIds: selectedItems.includes(id) ? selectedItems : [id],
};
event.dataTransfer.setData( event.dataTransfer.setData(
MIME_TYPES.excalidrawlib, MIME_TYPES.excalidrawlibIds,
serializeLibraryAsJSON(getInsertedElements(id)), JSON.stringify(data),
); );
}, },
[getInsertedElements], [selectedItems],
); );
const isItemSelected = useCallback( const isItemSelected = useCallback(
@@ -188,7 +226,6 @@ export default function LibraryMenuItems({
if (!id) { if (!id) {
return false; return false;
} }
return selectedItems.includes(id); return selectedItems.includes(id);
}, },
[selectedItems], [selectedItems],
@@ -208,10 +245,136 @@ export default function LibraryMenuItems({
); );
const itemsRenderedPerBatch = const itemsRenderedPerBatch =
svgCache.size >= libraryItems.length svgCache.size >=
(filteredItems.length ? filteredItems : libraryItems).length
? CACHED_ITEMS_RENDERED_PER_BATCH ? CACHED_ITEMS_RENDERED_PER_BATCH
: ITEMS_RENDERED_PER_BATCH; : ITEMS_RENDERED_PER_BATCH;
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// focus could be stolen by tab trigger button
nextAnimationFrame(() => {
searchInputRef.current?.focus();
});
}, []);
const JSX_whenNotSearching = !IS_SEARCHING && (
<>
{!IS_LIBRARY_EMPTY && (
<div className="library-menu-items-container__header">
{t("labels.personalLib")}
</div>
)}
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
{!publishedItems.length && (
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
)}
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
{publishedItems.length > 0 && (
<div
className="library-menu-items-container__header"
style={{ marginTop: "0.75rem" }}
>
{t("labels.excalidrawLib")}
</div>
)}
{publishedItems.length > 0 && (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</>
);
const JSX_whenSearching = IS_SEARCHING && (
<>
<div className="library-menu-items-container__header">
{t("library.search.heading")}
{!isLoading && (
<div
className="library-menu-items-container__header__hint"
style={{ cursor: "pointer" }}
onPointerDown={(e) => e.preventDefault()}
onClick={(event) => {
setSearchInputValue("");
}}
>
<kbd>esc</kbd> to clear
</div>
)}
</div>
{filteredItems.length > 0 ? (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={filteredItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__hint">
{t("library.search.noResults")}
</div>
<Button
onPointerDown={(e) => e.preventDefault()}
onSelect={() => {
setSearchInputValue("");
}}
style={{ width: "auto", marginTop: "1rem" }}
>
{t("library.search.clearSearch")}
</Button>
</div>
)}
</>
);
return ( return (
<div <div
className="library-menu-items-container" className="library-menu-items-container"
@@ -223,127 +386,58 @@ export default function LibraryMenuItems({
: { borderBottom: 0 } : { borderBottom: 0 }
} }
> >
{!isLibraryEmpty && ( <div className="library-menu-items-header">
{!IS_LIBRARY_EMPTY && (
<TextField
ref={searchInputRef}
type="search"
className={clsx("library-menu-items-container__search", {
hideCancelButton: !device.editor.isMobile,
})}
placeholder={t("library.search.inputPlaceholder")}
value={searchInputValue}
onChange={(value) => setSearchInputValue(value)}
/>
)}
<LibraryDropdownMenu <LibraryDropdownMenu
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={onSelectItems} onSelectItems={onSelectItems}
className="library-menu-dropdown-container--in-heading" className="library-menu-dropdown-container--in-heading"
/> />
)} </div>
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
align="start" align="start"
gap={1} gap={1}
style={{ style={{
flex: publishedItems.length > 0 ? 1 : "0 1 auto", flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0, margin: IS_LIBRARY_EMPTY ? "auto" : 0,
}} }}
ref={libraryContainerRef} ref={libraryContainerRef}
> >
<> {isLoading && (
{!isLibraryEmpty && ( <div
<div className="library-menu-items-container__header"> style={{
{t("labels.personalLib")} position: "absolute",
</div> top: "var(--container-padding-y)",
)} right: "var(--container-padding-x)",
{isLoading && ( transform: "translateY(50%)",
<div }}
style={{ >
position: "absolute", <Spinner />
top: "var(--container-padding-y)", </div>
right: "var(--container-padding-x)", )}
transform: "translateY(50%)",
}}
>
<Spinner />
</div>
)}
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</>
<> {JSX_whenNotSearching}
{(publishedItems.length > 0 || {JSX_whenSearching}
pendingElements.length > 0 ||
unpublishedItems.length > 0) && (
<div className="library-menu-items-container__header library-menu-items-container__header--excal">
{t("labels.excalidrawLib")}
</div>
)}
{publishedItems.length > 0 ? (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : unpublishedItems.length > 0 ? (
<div
style={{
margin: "1rem 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
</div>
) : null}
</>
{showBtn && ( {IS_LIBRARY_EMPTY && (
<LibraryMenuControlButtons <LibraryMenuControlButtons
style={{ padding: "16px 0", width: "100%" }} style={{ padding: "16px 0", width: "100%" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
> />
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/>
</LibraryMenuControlButtons>
)} )}
</Stack.Col> </Stack.Col>
</div> </div>
@@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
type LibraryOrPendingItem = ( type LibraryOrPendingItem = readonly (
| LibraryItem | LibraryItem
| /* pending library item */ { | /* pending library item */ {
id: null; id: null;
@@ -18,12 +18,12 @@
} }
&--hover { &--hover {
border-color: var(--color-primary); background-color: var(--color-surface-mid);
} }
&:active:not(:has(.library-unit__checkbox:hover)),
&--selected { &--selected {
border-color: var(--color-primary); background-color: var(--color-surface-high);
border-width: 1px;
} }
&--skeleton { &--skeleton {
+2 -18
View File
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { memo, useEffect, useRef, useState } from "react"; import { memo, useRef, useState } from "react";
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
@@ -33,23 +33,7 @@ export const LibraryUnit = memo(
svgCache: SvgCache; svgCache: SvgCache;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const svg = useLibraryItemSvg(id, elements, svgCache); const svg = useLibraryItemSvg(id, elements, svgCache, ref);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
if (svg) {
node.innerHTML = svg.outerHTML;
}
return () => {
node.innerHTML = "";
};
}, [svg]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().editor.isMobile; const isMobile = useDevice().editor.isMobile;
+77 -132
View File
@@ -1,32 +1,23 @@
import React from "react"; import React from "react";
import { showSelectedShapeActions } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { MobileShapeActions } from "./Actions";
import { MobileToolBar } from "./MobileToolBar";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { HandButton } from "./HandButton";
import { HintViewer } from "./HintViewer";
import { Island } from "./Island"; import { Island } from "./Island";
import { LockButton } from "./LockButton";
import { PenModeButton } from "./PenModeButton";
import { Section } from "./Section";
import Stack from "./Stack";
import type { ActionManager } from "../actions/manager"; import type { ActionManager } from "../actions/manager";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
AppState, AppState,
Device,
ExcalidrawProps,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { JSX } from "react"; import type { JSX } from "react";
@@ -38,7 +29,6 @@ type MobileMenuProps = {
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: AppClassProperties["togglePenMode"]; onPenModeToggle: AppClassProperties["togglePenMode"];
@@ -46,9 +36,11 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: UIAppState, appState: UIAppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderTopLeftUI?: (
isMobile: boolean,
appState: UIAppState,
) => JSX.Element | null;
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
app: AppClassProperties; app: AppClassProperties;
@@ -59,14 +51,10 @@ export const MobileMenu = ({
elements, elements,
actionManager, actionManager,
setAppState, setAppState,
onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
renderCustomStats,
renderSidebars, renderSidebars,
device,
renderWelcomeScreen, renderWelcomeScreen,
UIOptions, UIOptions,
app, app,
@@ -76,141 +64,98 @@ export const MobileMenu = ({
MainMenuTunnel, MainMenuTunnel,
DefaultSidebarTriggerTunnel, DefaultSidebarTriggerTunnel,
} = useTunnels(); } = useTunnels();
const renderToolbar = () => { const renderAppTopBar = () => {
return ( const topRightUI = renderTopRightUI?.(true, appState) ?? (
<FixedSideContainer side="top" className="App-top-bar"> <DefaultSidebarTriggerTunnel.Out />
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />} );
<Section heading="shapes">
{(heading: React.ReactNode) => ( const topLeftUI = (
<Stack.Col gap={4} align="center"> <div className="excalidraw-ui-top-left">
<Stack.Row gap={1} className="App-toolbar-container"> {renderTopLeftUI?.(true, appState)}
<Island padding={1} className="App-toolbar App-toolbar--mobile"> <MainMenuTunnel.Out />
{heading} </div>
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<DefaultSidebarTriggerTunnel.Out />
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
</div>
</Stack.Row>
</Stack.Col>
)}
</Section>
<HintViewer
appState={appState}
isMobile={true}
device={device}
app={app}
/>
</FixedSideContainer>
); );
};
const renderAppToolbar = () => {
if ( if (
appState.viewModeEnabled || appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector" appState.openDialog?.name === "elementLinkSelector"
) { ) {
return ( return <div className="App-toolbar-content">{topLeftUI}</div>;
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
</div>
);
} }
return ( return (
<div className="App-toolbar-content"> <div
<MainMenuTunnel.Out /> className="App-toolbar-content"
{actionManager.renderAction("toggleEditMenu")} style={{
{actionManager.renderAction( display: "flex",
appState.multiElement ? "finalize" : "duplicateSelection", flexDirection: "row",
)} justifyContent: "space-between",
{actionManager.renderAction("deleteSelectedElements")} }}
<div> >
{actionManager.renderAction("undo")} {topLeftUI}
{actionManager.renderAction("redo")} {topRightUI}
</div>
</div> </div>
); );
}; };
const renderToolbar = () => {
return (
<MobileToolBar
app={app}
onHandToolToggle={onHandToolToggle}
setAppState={setAppState}
/>
);
};
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
{!appState.viewModeEnabled && {/* welcome screen, bottom bar, and top bar all have the same z-index */}
appState.openDialog?.name !== "elementLinkSelector" && {/* ordered in this reverse order so that top bar is on top */}
renderToolbar()} <div className="App-welcome-screen">
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
</div>
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2, marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}} }}
> >
<Island padding={0}> <MobileShapeActions
{appState.openMenu === "shape" && appState={appState}
!appState.viewModeEnabled && elementsMap={app.scene.getNonDeletedElementsMap()}
appState.openDialog?.name !== "elementLinkSelector" && renderAction={actionManager.renderAction}
showSelectedShapeActions(appState, elements) ? ( app={app}
<Section className="App-mobile-menu" heading="selectedShapeActions"> setAppState={setAppState}
<SelectedShapeActions />
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} <Island className="App-toolbar">
renderAction={actionManager.renderAction} {!appState.viewModeEnabled &&
app={app} appState.openDialog?.name !== "elementLinkSelector" &&
/> renderToolbar()}
</Section> {appState.scrolledOutside &&
) : null} !appState.openMenu &&
<footer className="App-toolbar"> !appState.openSidebar && (
{renderAppToolbar()} <button
{appState.scrolledOutside && type="button"
!appState.openMenu && className="scroll-back-to-content"
!appState.openSidebar && ( onClick={() => {
<button setAppState((appState) => ({
type="button" ...calculateScrollCenter(elements, appState),
className="scroll-back-to-content" }));
onClick={() => { }}
setAppState((appState) => ({ >
...calculateScrollCenter(elements, appState), {t("buttons.scrollBackToContent")}
})); </button>
}} )}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island> </Island>
</div> </div>
<FixedSideContainer side="top" className="App-top-bar">
{renderAppTopBar()}
</FixedSideContainer>
</> </>
); );
}; };
@@ -0,0 +1,78 @@
@import "open-color/open-color.scss";
@import "../css/variables.module.scss";
.excalidraw {
.mobile-toolbar {
display: flex;
flex: 1;
align-items: center;
padding: 0px;
gap: 4px;
border-radius: var(--space-factor);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
justify-content: space-between;
}
.mobile-toolbar::-webkit-scrollbar {
display: none;
}
.mobile-toolbar .ToolIcon {
min-width: 2rem;
min-height: 2rem;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.ToolIcon__icon {
width: 2.25rem;
height: 2.25rem;
&:hover {
background-color: transparent;
}
}
&.active {
background: var(
--color-surface-primary-container,
var(--island-bg-color)
);
border-color: var(--button-active-border, var(--color-primary-darkest));
}
svg {
width: 1rem;
height: 1rem;
}
}
.mobile-toolbar .App-toolbar__extra-tools-dropdown {
min-width: 160px;
z-index: var(--zIndex-layerUI);
}
.mobile-toolbar-separator {
width: 1px;
height: 24px;
background: var(--default-border-color);
margin: 0 2px;
flex-shrink: 0;
}
.mobile-toolbar-undo {
display: flex;
align-items: center;
}
.mobile-toolbar-undo .ToolIcon {
min-width: 32px;
min-height: 32px;
width: 32px;
height: 32px;
}
}
@@ -0,0 +1,474 @@
import { useState, useEffect, useRef } from "react";
import clsx from "clsx";
import { KEYS, capitalizeString } from "@excalidraw/common";
import { trackEvent } from "../analytics";
import { t } from "../i18n";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels";
import { HandButton } from "./HandButton";
import { ToolButton } from "./ToolButton";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { ToolPopover } from "./ToolPopover";
import {
SelectionIcon,
FreedrawIcon,
EraserIcon,
RectangleIcon,
ArrowIcon,
extraToolsIcon,
DiamondIcon,
EllipseIcon,
LineIcon,
TextIcon,
ImageIcon,
frameToolIcon,
EmbedIcon,
laserPointerToolIcon,
LassoIcon,
mermaidLogoIcon,
MagicIcon,
} from "./icons";
import "./ToolIcon.scss";
import "./MobileToolBar.scss";
import type { AppClassProperties, ToolType, UIAppState } from "../types";
const SHAPE_TOOLS = [
{
type: "rectangle",
icon: RectangleIcon,
title: capitalizeString(t("toolBar.rectangle")),
},
{
type: "diamond",
icon: DiamondIcon,
title: capitalizeString(t("toolBar.diamond")),
},
{
type: "ellipse",
icon: EllipseIcon,
title: capitalizeString(t("toolBar.ellipse")),
},
] as const;
const SELECTION_TOOLS = [
{
type: "selection",
icon: SelectionIcon,
title: capitalizeString(t("toolBar.selection")),
},
{
type: "lasso",
icon: LassoIcon,
title: capitalizeString(t("toolBar.lasso")),
},
] as const;
const LINEAR_ELEMENT_TOOLS = [
{
type: "arrow",
icon: ArrowIcon,
title: capitalizeString(t("toolBar.arrow")),
},
{ type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) },
] as const;
type MobileToolBarProps = {
app: AppClassProperties;
onHandToolToggle: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
};
export const MobileToolBar = ({
app,
onHandToolToggle,
setAppState,
}: MobileToolBarProps) => {
const activeTool = app.state.activeTool;
const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
"rectangle" | "diamond" | "ellipse"
>("rectangle");
const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
"arrow" | "line"
>("arrow");
const toolbarRef = useRef<HTMLDivElement>(null);
// keep lastActiveGenericShape in sync with active tool if user switches via other UI
useEffect(() => {
if (
activeTool.type === "rectangle" ||
activeTool.type === "diamond" ||
activeTool.type === "ellipse"
) {
setLastActiveGenericShape(activeTool.type);
}
}, [activeTool.type]);
// keep lastActiveLinearElement in sync with active tool if user switches via other UI
useEffect(() => {
if (activeTool.type === "arrow" || activeTool.type === "line") {
setLastActiveLinearElement(activeTool.type);
}
}, [activeTool.type]);
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
const handleToolChange = (toolType: string, pointerType?: string) => {
if (app.state.activeTool.type !== toolType) {
trackEvent("toolbar", toolType, "ui");
}
if (toolType === "selection") {
if (app.state.activeTool.type === "selection") {
// Toggle selection tool behavior if needed
} else {
app.setActiveTool({ type: "selection" });
}
} else {
app.setActiveTool({ type: toolType as ToolType });
}
};
const toolbarWidth =
toolbarRef.current?.getBoundingClientRect()?.width ?? 0 - 8;
const WIDTH = 36;
const GAP = 4;
// hand, selection, freedraw, eraser, rectangle, arrow, others
const MIN_TOOLS = 7;
const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP;
const ADDITIONAL_WIDTH = WIDTH + GAP;
const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH;
const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
const extraTools = [
"text",
"frame",
"embeddable",
"laser",
"magicframe",
].filter((tool) => {
if (showImageToolOutside && tool === "image") {
return false;
}
if (showFrameToolOutside && tool === "frame") {
return false;
}
return true;
});
const extraToolSelected = extraTools.includes(activeTool.type);
const extraIcon = extraToolSelected
? activeTool.type === "frame"
? frameToolIcon
: activeTool.type === "embeddable"
? EmbedIcon
: activeTool.type === "laser"
? laserPointerToolIcon
: activeTool.type === "text"
? TextIcon
: activeTool.type === "magicframe"
? MagicIcon
: extraToolsIcon
: extraToolsIcon;
return (
<div className="mobile-toolbar" ref={toolbarRef}>
{/* Hand Tool */}
<HandButton
checked={isHandToolActive(app.state)}
onChange={onHandToolToggle}
title={t("toolBar.hand")}
isMobile
/>
{/* Selection Tool */}
<ToolPopover
app={app}
options={SELECTION_TOOLS}
activeTool={activeTool}
defaultOption={app.state.preferredSelectionTool.type}
namePrefix="selectionType"
title={capitalizeString(t("toolBar.selection"))}
data-testid="toolbar-selection"
onToolChange={(type: string) => {
if (type === "selection" || type === "lasso") {
app.setActiveTool({ type });
setAppState({
preferredSelectionTool: { type, initialized: true },
});
}
}}
displayedOption={
SELECTION_TOOLS.find(
(tool) => tool.type === app.state.preferredSelectionTool.type,
) || SELECTION_TOOLS[0]
}
/>
{/* Free Draw */}
<ToolButton
className={clsx({
active: activeTool.type === "freedraw",
})}
type="radio"
icon={FreedrawIcon}
checked={activeTool.type === "freedraw"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.freedraw"))}`}
aria-label={capitalizeString(t("toolBar.freedraw"))}
data-testid="toolbar-freedraw"
onChange={() => handleToolChange("freedraw")}
/>
{/* Eraser */}
<ToolButton
className={clsx({
active: activeTool.type === "eraser",
})}
type="radio"
icon={EraserIcon}
checked={activeTool.type === "eraser"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.eraser"))}`}
aria-label={capitalizeString(t("toolBar.eraser"))}
data-testid="toolbar-eraser"
onChange={() => handleToolChange("eraser")}
/>
{/* Rectangle */}
<ToolPopover
app={app}
options={SHAPE_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveGenericShape}
namePrefix="shapeType"
title={capitalizeString(
t(
lastActiveGenericShape === "rectangle"
? "toolBar.rectangle"
: lastActiveGenericShape === "diamond"
? "toolBar.diamond"
: lastActiveGenericShape === "ellipse"
? "toolBar.ellipse"
: "toolBar.rectangle",
),
)}
data-testid="toolbar-rectangle"
onToolChange={(type: string) => {
if (
type === "rectangle" ||
type === "diamond" ||
type === "ellipse"
) {
setLastActiveGenericShape(type);
app.setActiveTool({ type });
}
}}
displayedOption={
SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) ||
SHAPE_TOOLS[0]
}
/>
{/* Arrow/Line */}
<ToolPopover
app={app}
options={LINEAR_ELEMENT_TOOLS}
activeTool={activeTool}
defaultOption={lastActiveLinearElement}
namePrefix="linearElementType"
title={capitalizeString(
t(
lastActiveLinearElement === "arrow"
? "toolBar.arrow"
: "toolBar.line",
),
)}
data-testid="toolbar-arrow"
fillable={true}
onToolChange={(type: string) => {
if (type === "arrow" || type === "line") {
setLastActiveLinearElement(type);
app.setActiveTool({ type });
}
}}
displayedOption={
LINEAR_ELEMENT_TOOLS.find(
(tool) => tool.type === lastActiveLinearElement,
) || LINEAR_ELEMENT_TOOLS[0]
}
/>
{/* Text Tool */}
{showTextToolOutside && (
<ToolButton
className={clsx({
active: activeTool.type === "text",
})}
type="radio"
icon={TextIcon}
checked={activeTool.type === "text"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.text"))}`}
aria-label={capitalizeString(t("toolBar.text"))}
data-testid="toolbar-text"
onChange={() => handleToolChange("text")}
/>
)}
{/* Image */}
{showImageToolOutside && (
<ToolButton
className={clsx({
active: activeTool.type === "image",
})}
type="radio"
icon={ImageIcon}
checked={activeTool.type === "image"}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.image"))}`}
aria-label={capitalizeString(t("toolBar.image"))}
data-testid="toolbar-image"
onChange={() => handleToolChange("image")}
/>
)}
{/* Frame Tool */}
{showFrameToolOutside && (
<ToolButton
className={clsx({ active: frameToolSelected })}
type="radio"
icon={frameToolIcon}
checked={frameToolSelected}
name="editor-current-shape"
title={`${capitalizeString(t("toolBar.frame"))}`}
aria-label={capitalizeString(t("toolBar.frame"))}
data-testid="toolbar-frame"
onChange={() => handleToolChange("frame")}
/>
)}
{/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
<DropdownMenu.Trigger
className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
{
"App-toolbar__extra-tools-trigger--selected":
extraToolSelected || isOtherShapesMenuOpen,
},
)}
onToggle={() => {
setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen);
setAppState({ openMenu: null, openPopup: null });
}}
title={t("toolBar.extraTools")}
style={{
width: WIDTH,
height: WIDTH,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{extraIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
{!showTextToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "text" })}
icon={TextIcon}
shortcut={KEYS.T.toLocaleUpperCase()}
data-testid="toolbar-text"
selected={activeTool.type === "text"}
>
{t("toolBar.text")}
</DropdownMenu.Item>
)}
{!showImageToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "image" })}
icon={ImageIcon}
data-testid="toolbar-image"
selected={activeTool.type === "image"}
>
{t("toolBar.image")}
</DropdownMenu.Item>
)}
{!showFrameToolOutside && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
)}
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "embeddable" })}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu>
</div>
);
};
+5 -1
View File
@@ -3,6 +3,8 @@ import { unstable_batchedUpdates } from "react-dom";
import { KEYS, queryFocusableElements } from "@excalidraw/common"; import { KEYS, queryFocusableElements } from "@excalidraw/common";
import clsx from "clsx";
import "./Popover.scss"; import "./Popover.scss";
type Props = { type Props = {
@@ -15,6 +17,7 @@ type Props = {
offsetTop?: number; offsetTop?: number;
viewportWidth?: number; viewportWidth?: number;
viewportHeight?: number; viewportHeight?: number;
className?: string;
}; };
export const Popover = ({ export const Popover = ({
@@ -27,6 +30,7 @@ export const Popover = ({
offsetTop = 0, offsetTop = 0,
viewportWidth = window.innerWidth, viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight, viewportHeight = window.innerHeight,
className,
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@@ -146,7 +150,7 @@ export const Popover = ({
}, [onCloseRequest]); }, [onCloseRequest]);
return ( return (
<div className="popover" ref={popoverRef} tabIndex={-1}> <div className={clsx("popover", className)} ref={popoverRef} tabIndex={-1}>
{children} {children}
</div> </div>
); );
@@ -40,6 +40,8 @@ export const PropertiesPopover = React.forwardRef<
ref, ref,
) => { ) => {
const device = useDevice(); const device = useDevice();
const isMobilePortrait =
device.editor.isMobile && !device.viewport.isLandscape;
return ( return (
<Popover.Portal container={container}> <Popover.Portal container={container}>
@@ -47,20 +49,14 @@ export const PropertiesPopover = React.forwardRef<
ref={ref} ref={ref}
className={clsx("focus-visible-none", className)} className={clsx("focus-visible-none", className)}
data-prevent-outside-click data-prevent-outside-click
side={ side={isMobilePortrait ? "bottom" : "right"}
device.editor.isMobile && !device.viewport.isLandscape align={isMobilePortrait ? "center" : "start"}
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16} alignOffset={-16}
sideOffset={20} sideOffset={20}
collisionBoundary={container ?? undefined}
style={{ style={{
zIndex: "var(--zIndex-popup)", zIndex: "var(--zIndex-ui-styles-popup)",
marginLeft: device.editor.isMobile ? "0.5rem" : undefined,
}} }}
onPointerLeave={onPointerLeave} onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -518,7 +518,7 @@ const PublishLibrary = ({
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
<DialogActionButton <DialogActionButton
label={t("buttons.cancel")} label={t("buttons.saveLibNames")}
onClick={onDialogClose} onClick={onDialogClose}
data-testid="cancel-clear-canvas-button" data-testid="cancel-clear-canvas-button"
/> />
@@ -9,7 +9,7 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 5; z-index: var(--zIndex-ui-library);
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -9,7 +9,13 @@ import React, {
useCallback, useCallback,
} from "react"; } from "react";
import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common"; import {
CLASSES,
EVENT,
isDevEnv,
KEYS,
updateObject,
} from "@excalidraw/common";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { atom, useSetAtom } from "../../editor-jotai"; import { atom, useSetAtom } from "../../editor-jotai";
@@ -137,7 +143,11 @@ export const SidebarInner = forwardRef(
return ( return (
<Island <Island
{...rest} {...rest}
className={clsx("sidebar", { "sidebar--docked": docked }, className)} className={clsx(
CLASSES.SIDEBAR,
{ "sidebar--docked": docked },
className,
)}
ref={islandRef} ref={islandRef}
> >
<SidebarPropsContext.Provider value={headerPropsRef.current}> <SidebarPropsContext.Provider value={headerPropsRef.current}>
@@ -30,7 +30,11 @@ export const SidebarTrigger = ({
.querySelector(".layer-ui__wrapper") .querySelector(".layer-ui__wrapper")
?.classList.remove("animate"); ?.classList.remove("animate");
const isOpen = event.target.checked; const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? { name, tab } : null }); setAppState({
openSidebar: isOpen ? { name, tab } : null,
openMenu: null,
openPopup: null,
});
onToggle?.(isOpen); onToggle?.(isOpen);
}} }}
checked={appState.openSidebar?.name === name} checked={appState.openSidebar?.name === name}
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/common"; import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
export const TTDDialogSubmitShortcut = () => { export const TTDDialogSubmitShortcut = () => {
return ( return (
@@ -1,12 +1,11 @@
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState } from "../App"; import { useExcalidrawSetAppState } from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu"; import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { brainIcon } from "../icons"; import { brainIcon } from "../icons";
import type { ReactNode } from "react"; import type { JSX, ReactNode } from "react";
import type { JSX } from "react";
export const TTDDialogTrigger = ({ export const TTDDialogTrigger = ({
children, children,
@@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({
children?: ReactNode; children?: ReactNode;
icon?: JSX.Element; icon?: JSX.Element;
}) => { }) => {
const { t } = useI18n();
const { TTDDialogTriggerTunnel } = useTunnels(); const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
@@ -12,6 +12,10 @@
--ExcTextField--border-active: var(--color-brand-active); --ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant); --ExcTextField--placeholder: var(--color-border-outline-variant);
&.theme--dark {
--ExcTextField--border: var(--color-border-outline-variant);
}
.ExcTextField { .ExcTextField {
position: relative; position: relative;
@@ -28,6 +28,7 @@ type TextFieldProps = {
className?: string; className?: string;
placeholder?: string; placeholder?: string;
isRedacted?: boolean; isRedacted?: boolean;
type?: "text" | "search";
} & ({ value: string } | { defaultValue: string }); } & ({ value: string } | { defaultValue: string });
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -43,6 +44,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
isRedacted = false, isRedacted = false,
icon, icon,
className, className,
type,
...rest ...rest
}, },
ref, ref,
@@ -96,6 +98,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
ref={innerRef} ref={innerRef}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
type={type}
/> />
{isRedacted && ( {isRedacted && (
<Button <Button
@@ -0,0 +1,18 @@
@import "../css/variables.module.scss";
.excalidraw {
.tool-popover-content {
display: flex;
flex-direction: row;
gap: 0.25rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.5rem;
z-index: var(--zIndex-layerUI);
}
&:focus {
outline: none;
}
}
@@ -0,0 +1,124 @@
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import { capitalizeString } from "@excalidraw/common";
import * as Popover from "@radix-ui/react-popover";
import { trackEvent } from "../analytics";
import { ToolButton } from "./ToolButton";
import "./ToolPopover.scss";
import { useExcalidrawContainer } from "./App";
import type { AppClassProperties } from "../types";
type ToolOption = {
type: string;
icon: React.ReactNode;
title?: string;
};
type ToolPopoverProps = {
app: AppClassProperties;
options: readonly ToolOption[];
activeTool: { type: string };
defaultOption: string;
className?: string;
namePrefix: string;
title: string;
"data-testid": string;
onToolChange: (type: string) => void;
displayedOption: ToolOption;
fillable?: boolean;
};
export const ToolPopover = ({
app,
options,
activeTool,
defaultOption,
className = "Shape",
namePrefix,
title,
"data-testid": dataTestId,
onToolChange,
displayedOption,
fillable = false,
}: ToolPopoverProps) => {
const [isPopupOpen, setIsPopupOpen] = useState(false);
const currentType = activeTool.type;
const isActive = displayedOption.type === currentType;
const SIDE_OFFSET = 32 / 2 + 10;
const { container } = useExcalidrawContainer();
// if currentType is not in options, close popup
if (!options.some((o) => o.type === currentType) && isPopupOpen) {
setIsPopupOpen(false);
}
// Close popover when user starts interacting with the canvas (pointer down)
useEffect(() => {
// app.onPointerDownEmitter emits when pointer down happens on canvas area
const unsubscribe = app.onPointerDownEmitter.on(() => {
setIsPopupOpen(false);
});
return () => unsubscribe?.();
}, [app]);
return (
<Popover.Root open={isPopupOpen}>
<Popover.Trigger asChild>
<ToolButton
className={clsx(className, {
fillable,
active: options.some((o) => o.type === activeTool.type),
})}
type="radio"
icon={displayedOption.icon}
checked={isActive}
name="editor-current-shape"
title={title}
aria-label={title}
data-testid={dataTestId}
onPointerDown={() => {
setIsPopupOpen((v) => !v);
onToolChange(defaultOption);
}}
/>
</Popover.Trigger>
<Popover.Content
className="tool-popover-content"
sideOffset={SIDE_OFFSET}
collisionBoundary={container ?? undefined}
>
{options.map(({ type, icon, title }) => (
<ToolButton
className={clsx(className, {
active: currentType === type,
})}
key={type}
type="radio"
icon={icon}
checked={currentType === type}
name={`${namePrefix}-option`}
title={title || capitalizeString(type)}
keyBindingLabel=""
aria-label={title || capitalizeString(type)}
data-testid={`toolbar-${type}`}
onChange={() => {
if (app.state.activeTool.type !== type) {
trackEvent("toolbar", type, "ui");
}
app.setActiveTool({ type: type as any });
onToolChange?.(type);
}}
/>
))}
</Popover.Content>
</Popover.Root>
);
};
@@ -44,6 +44,10 @@
var(--button-active-border, var(--color-primary-darkest)) inset; var(--button-active-border, var(--color-primary-darkest)) inset;
} }
&:hover {
background-color: transparent;
}
&--selected, &--selected,
&--selected:hover { &--selected:hover {
background: var(--color-primary-light); background: var(--color-primary-light);
@@ -3,24 +3,45 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: 100%; top: 2.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
max-width: 16rem;
&--placement-top {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.5rem;
}
&--mobile { &--mobile {
left: 0;
width: 100%; width: 100%;
row-gap: 0.75rem; row-gap: 0.75rem;
// When main menu is in the top toolbar, position relative to trigger
&.main-menu-dropdown {
min-width: 232px;
margin-top: 0;
margin-bottom: 0;
@media screen and (orientation: landscape) {
max-width: 232px;
}
}
.dropdown-menu-container { .dropdown-menu-container {
padding: 8px 8px; padding: 8px 8px;
box-sizing: border-box; box-sizing: border-box;
// background-color: var(--island-bg-color); max-height: calc(
100svh - var(--editor-container-padding) * 2 - 2.25rem
);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
position: relative; position: relative;
transition: box-shadow 0.5s ease-in-out; transition: box-shadow 0.5s ease-in-out;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
&.zen-mode { &.zen-mode {
box-shadow: none; box-shadow: none;
@@ -30,7 +51,7 @@
.dropdown-menu-container { .dropdown-menu-container {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
max-height: calc(100vh - 150px);
overflow-y: auto; overflow-y: auto;
--gap: 2; --gap: 2;
} }
@@ -17,16 +17,27 @@ import "./DropdownMenu.scss";
const DropdownMenu = ({ const DropdownMenu = ({
children, children,
open, open,
placement,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
open: boolean; open: boolean;
placement?: "top" | "bottom";
}) => { }) => {
const MenuTriggerComp = getMenuTriggerComponent(children); const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children); const MenuContentComp = getMenuContentComponent(children);
// clone the MenuContentComp to pass the placement prop
const MenuContentCompWithPlacement =
MenuContentComp && React.isValidElement(MenuContentComp)
? React.cloneElement(MenuContentComp as React.ReactElement<any>, {
placement,
})
: MenuContentComp;
return ( return (
<> <>
{MenuTriggerComp} {MenuTriggerComp}
{open && MenuContentComp} {open && MenuContentCompWithPlacement}
</> </>
); );
}; };
@@ -17,6 +17,7 @@ const MenuContent = ({
className = "", className = "",
onSelect, onSelect,
style, style,
placement = "bottom",
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
@@ -26,6 +27,7 @@ const MenuContent = ({
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
placement?: "top" | "bottom";
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -58,6 +60,7 @@ const MenuContent = ({
const classNames = clsx(`dropdown-menu ${className}`, { const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile, "dropdown-menu--mobile": device.editor.isMobile,
"dropdown-menu--placement-top": placement === "top",
}).trim(); }).trim();
return ( return (
@@ -71,7 +74,12 @@ const MenuContent = ({
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */} see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.editor.isMobile ? ( {device.editor.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col> <Stack.Col
className="dropdown-menu-container"
style={{ ["--gap" as any]: 1.25 }}
>
{children}
</Stack.Col>
) : ( ) : (
<Island <Island
className="dropdown-menu-container" className="dropdown-menu-container"
@@ -6,6 +6,7 @@ const MenuSeparator = () => (
height: "1px", height: "1px",
backgroundColor: "var(--default-border-color)", backgroundColor: "var(--default-border-color)",
margin: ".5rem 0", margin: ".5rem 0",
flex: "0 0 auto",
}} }}
/> />
); );
+1 -13
View File
@@ -2319,22 +2319,10 @@ export const adjustmentsIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const backgroundIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 10l4 -4" />
<path d="M6 14l8 -8" />
<path d="M6 18l12 -12" />
<path d="M10 18l8 -8" />
<path d="M14 18l4 -4" />
</g>,
tablerIconProps,
);
export const strokeIcon = createIcon( export const strokeIcon = createIcon(
<g strokeWidth={1}> <g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="6" width="12" height="12" fill="none" /> <path d="M6 10l4 -4 L6 14l8 -8 L6 18l12 -12 L10 18l8 -8 L14 18l4 -4" />
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
@@ -30,9 +30,6 @@ const MainMenu = Object.assign(
const device = useDevice(); const device = useDevice();
const appState = useUIAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.editor.isMobile
? undefined
: () => setAppState({ openMenu: null });
return ( return (
<MainMenuTunnel.In> <MainMenuTunnel.In>
@@ -41,6 +38,8 @@ const MainMenu = Object.assign(
onToggle={() => { onToggle={() => {
setAppState({ setAppState({
openMenu: appState.openMenu === "canvas" ? null : "canvas", openMenu: appState.openMenu === "canvas" ? null : "canvas",
openPopup: null,
openDialog: null,
}); });
}} }}
data-testid="main-menu-trigger" data-testid="main-menu-trigger"
@@ -49,10 +48,12 @@ const MainMenu = Object.assign(
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
onClickOutside={onClickOutside} onClickOutside={() => setAppState({ openMenu: null })}
onSelect={composeEventHandlers(onSelect, () => { onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
})} })}
placement="bottom"
className={device.editor.isMobile ? "main-menu-dropdown" : ""}
> >
{children} {children}
{device.editor.isMobile && appState.collaborators.size > 0 && ( {device.editor.isMobile && appState.collaborators.size > 0 && (
+1 -1
View File
@@ -89,7 +89,7 @@ export const SHAPES = [
] as const; ] as const;
export const getToolbarTools = (app: AppClassProperties) => { export const getToolbarTools = (app: AppClassProperties) => {
return app.defaultSelectionTool === "lasso" return app.state.preferredSelectionTool.type === "lasso"
? ([ ? ([
{ {
value: "lasso", value: "lasso",
@@ -252,16 +252,12 @@
} }
} }
@media (max-height: 599px) { &.excalidraw--mobile {
.welcome-screen-center { .welcome-screen-center {
margin-top: 4rem; margin-bottom: 2rem;
}
}
@media (min-height: 600px) and (max-height: 900px) {
.welcome-screen-center {
margin-top: 8rem;
} }
} }
@media (max-height: 500px), (max-width: 320px) { @media (max-height: 500px), (max-width: 320px) {
.welcome-screen-center { .welcome-screen-center {
display: none; display: none;
+40 -18
View File
@@ -12,6 +12,12 @@
--zIndex-eyeDropperPreview: 6; --zIndex-eyeDropperPreview: 6;
--zIndex-hyperlinkContainer: 7; --zIndex-hyperlinkContainer: 7;
--zIndex-ui-bottom: 60;
--zIndex-ui-library: 80;
--zIndex-ui-context-menu: 90;
--zIndex-ui-styles-popup: 100;
--zIndex-ui-top: 100;
--zIndex-modal: 1000; --zIndex-modal: 1000;
--zIndex-popup: 1001; --zIndex-popup: 1001;
--zIndex-toast: 999999; --zIndex-toast: 999999;
@@ -44,6 +50,11 @@ body.excalidraw-cursor-resize * {
height: 100%; height: 100%;
width: 100%; width: 100%;
button,
label {
@include buttonNoHighlight;
}
button { button {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -232,30 +243,35 @@ body.excalidraw-cursor-resize * {
} }
.App-top-bar { .App-top-bar {
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-ui-top);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; }
.App-welcome-screen {
z-index: var(--zIndex-layerUI);
} }
.App-bottom-bar { .App-bottom-bar {
position: absolute; position: absolute;
top: 0; // account for margins
width: calc(100% - 28px);
max-width: 450px;
bottom: 0; bottom: 0;
left: 0; left: 50%;
right: 0; transform: translateX(-50%);
--bar-padding: calc(4 * var(--space-factor)); --bar-padding: calc(4 * var(--space-factor));
z-index: 4; z-index: var(--zIndex-ui-bottom);
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
pointer-events: none; pointer-events: none;
justify-content: center;
> .Island { > .Island {
width: 100%;
max-width: 100%;
min-width: 100%;
box-sizing: border-box; box-sizing: border-box;
max-height: 100%; max-height: 100%;
padding: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
@@ -263,7 +279,8 @@ body.excalidraw-cursor-resize * {
} }
.App-toolbar { .App-toolbar {
width: 100%; display: flex;
justify-content: center;
.eraser { .eraser {
&.ToolIcon:hover { &.ToolIcon:hover {
@@ -276,15 +293,20 @@ body.excalidraw-cursor-resize * {
} }
} }
.App-toolbar-content { .excalidraw-ui-top-left {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.5rem;
padding: 8px; }
.dropdown-menu--mobile { .App-toolbar-content {
bottom: 55px; display: flex;
top: auto; flex-direction: column;
pointer-events: none;
& > * {
pointer-events: var(--ui-pointerEvents);
} }
} }
@@ -506,7 +528,7 @@ body.excalidraw-cursor-resize * {
display: none; display: none;
} }
.scroll-back-to-content { .scroll-back-to-content {
bottom: calc(80px + var(--sab, 0)); bottom: calc(100px + var(--sab, 0));
z-index: -1; z-index: -1;
} }
} }
+9
View File
@@ -8,6 +8,8 @@
--button-gray-1: #{$oc-gray-2}; --button-gray-1: #{$oc-gray-2};
--button-gray-2: #{$oc-gray-4}; --button-gray-2: #{$oc-gray-4};
--button-gray-3: #{$oc-gray-5}; --button-gray-3: #{$oc-gray-5};
--mobile-action-button-bg: rgba(255, 255, 255, 0.35);
--mobile-color-border: var(--default-border-color);
--button-special-active-bg-color: #{$oc-green-0}; --button-special-active-bg-color: #{$oc-green-0};
--dialog-border-color: var(--color-gray-20); --dialog-border-color: var(--color-gray-20);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>'); --dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
@@ -42,6 +44,11 @@
--lg-button-size: 2.25rem; --lg-button-size: 2.25rem;
--lg-icon-size: 1rem; --lg-icon-size: 1rem;
--editor-container-padding: 1rem; --editor-container-padding: 1rem;
--mobile-action-button-size: 2rem;
@include isMobile {
--editor-container-padding: 0.75rem;
}
@media screen and (min-device-width: 1921px) { @media screen and (min-device-width: 1921px) {
--lg-button-size: 2.5rem; --lg-button-size: 2.5rem;
@@ -177,6 +184,8 @@
--button-gray-1: #363636; --button-gray-1: #363636;
--button-gray-2: #272727; --button-gray-2: #272727;
--button-gray-3: #222; --button-gray-3: #222;
--mobile-action-button-bg: var(--island-bg-color);
--mobile-color-border: rgba(255, 255, 255, 0.85);
--button-special-active-bg-color: #204624; --button-special-active-bg-color: #204624;
--dialog-border-color: var(--color-gray-80); --dialog-border-color: var(--color-gray-80);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>'); --dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
@@ -122,6 +122,17 @@
color: var(--button-color, var(--color-on-primary-container)); color: var(--button-color, var(--color-on-primary-container));
} }
} }
@include isMobile() {
width: var(--mobile-action-button-size, var(--default-button-size));
height: var(--mobile-action-button-size, var(--default-button-size));
}
}
@mixin buttonNoHighlight {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
} }
@mixin outlineButtonIconStyles { @mixin outlineButtonIconStyles {
@@ -187,4 +198,9 @@
&:active { &:active {
box-shadow: 0 0 0 1px var(--color-brand-active); box-shadow: 0 0 0 1px var(--color-brand-active);
} }
@include isMobile() {
width: var(--mobile-action-button-size, 2rem);
height: var(--mobile-action-button-size, 2rem);
}
} }
+40 -42
View File
@@ -25,7 +25,7 @@ import { restore, restoreLibraryItems } from "./restore";
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "./filesystem"; import type { FileSystemHandle } from "browser-fs-access";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -416,37 +416,42 @@ export const getFileHandle = async (
/** /**
* attempts to detect if a buffer is a valid image by checking its leading bytes * attempts to detect if a buffer is a valid image by checking its leading bytes
*/ */
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { const getActualMimeTypeFromImage = async (file: Blob | File) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null = let mimeType: ValueOf<
null; Pick<typeof MIME_TYPES, "png" | "jpg" | "gif" | "webp">
> | null = null;
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `; const leadingBytes = [
...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))),
].join(" ");
// uint8 leading bytes // uint8 leading bytes
const headerBytes = { const bytes = {
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
png: "137 80 78 71 13 10 26 10 ", png: /^137 80 78 71 13 10 26 10\b/,
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
// jpg is a bit wonky. Checking the first three bytes should be enough, // jpg is a bit wonky. Checking the first three bytes should be enough,
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631) // but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
jpg: "255 216 255 ", jpg: /^255 216 255\b/,
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file // https://en.wikipedia.org/wiki/GIF#Example_GIF_file
gif: "71 73 70 56 57 97 ", gif: /^71 73 70 56 57 97\b/,
// 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier
webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56\b/,
}; };
if (first8Bytes === headerBytes.png) { for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) {
mimeType = MIME_TYPES.png; if (leadingBytes.match(bytes[type])) {
} else if (first8Bytes.startsWith(headerBytes.jpg)) { mimeType = MIME_TYPES[type];
mimeType = MIME_TYPES.jpg; break;
} else if (first8Bytes.startsWith(headerBytes.gif)) { }
mimeType = MIME_TYPES.gif;
} }
return mimeType;
return mimeType || file.type || null;
}; };
export const createFile = ( export const createFile = (
blob: File | Blob | ArrayBuffer, blob: File | Blob | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>, mimeType: string,
name: string | undefined, name: string | undefined,
) => { ) => {
return new File([blob], name || "", { return new File([blob], name || "", {
@@ -454,40 +459,33 @@ export const createFile = (
}); });
}; };
const normalizedFileSymbol = Symbol("fileNormalized");
/** attempts to detect correct mimeType if none is set, or if an image /** attempts to detect correct mimeType if none is set, or if an image
* has an incorrect extension. * has an incorrect extension.
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
export const normalizeFile = async (file: File) => { export const normalizeFile = async (file: File) => {
if (!file.type) { // to prevent double normalization (perf optim)
if (file?.name?.endsWith(".excalidrawlib")) { if ((file as any)[normalizedFileSymbol]) {
file = createFile( return file;
await blobToArrayBuffer(file), }
MIME_TYPES.excalidrawlib,
file.name, if (file?.name?.endsWith(".excalidrawlib")) {
); file = createFile(file, MIME_TYPES.excalidrawlib, file.name);
} else if (file?.name?.endsWith(".excalidraw")) { } else if (file?.name?.endsWith(".excalidraw")) {
file = createFile( file = createFile(file, MIME_TYPES.excalidraw, file.name);
await blobToArrayBuffer(file), } else if (!file.type || file.type?.startsWith("image/")) {
MIME_TYPES.excalidraw,
file.name,
);
} else {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType) {
file = createFile(buffer, mimeType, file.name);
}
}
// when the file is an image, make sure the extension corresponds to the // when the file is an image, make sure the extension corresponds to the
// actual mimeType (this is an edge case, but happens sometime) // actual mimeType (this is an edge case, but happens - especially
} else if (isSupportedImageFile(file)) { // with AI generated images)
const buffer = await blobToArrayBuffer(file); const mimeType = await getActualMimeTypeFromImage(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType && mimeType !== file.type) { if (mimeType && mimeType !== file.type) {
file = createFile(buffer, mimeType, file.name); file = createFile(file, mimeType, file.name);
} }
} }
(file as any)[normalizedFileSymbol] = true;
return file; return file;
}; };
+13 -4
View File
@@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { normalizeFile } from "./blob";
import type { FileSystemHandle } from "browser-fs-access"; import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 5000;
export const fileOpen = <M extends boolean | undefined = false>(opts: { export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
description: string; description: string;
multiple?: M; multiple?: M;
@@ -35,7 +37,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
return acc.concat(`.${ext}`); return acc.concat(`.${ext}`);
}, [] as string[]); }, [] as string[]);
return _fileOpen({ const files = await _fileOpen({
description: opts.description, description: opts.description,
extensions, extensions,
mimeTypes, mimeTypes,
@@ -74,7 +76,14 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
} }
}; };
}, },
}) as Promise<RetType>; });
if (Array.isArray(files)) {
return (await Promise.all(
files.map((file) => normalizeFile(file)),
)) as RetType;
}
return (await normalizeFile(files)) as RetType;
}; };
export const fileSave = ( export const fileSave = (
+2 -7
View File
@@ -15,7 +15,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob"; import { isImageFileHandle, loadFromBlob } from "./blob";
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "./filesystem";
import type { AppState, BinaryFiles, LibraryItems } from "../types"; import type { AppState, BinaryFiles, LibraryItems } from "../types";
@@ -108,12 +108,7 @@ export const loadFromJSON = async (
// gets resolved. Else, iOS users cannot open `.excalidraw` files. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"], // extensions: ["json", "excalidraw", "png", "svg"],
}); });
return loadFromBlob( return loadFromBlob(file, localAppState, localElements, file.handle);
await normalizeFile(file),
localAppState,
localElements,
file.handle,
);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {
+19 -4
View File
@@ -62,6 +62,7 @@ type LibraryUpdate = {
deletedItems: Map<LibraryItem["id"], LibraryItem>; deletedItems: Map<LibraryItem["id"], LibraryItem>;
/** newly added items in the library */ /** newly added items in the library */
addedItems: Map<LibraryItem["id"], LibraryItem>; addedItems: Map<LibraryItem["id"], LibraryItem>;
updatedItems: Map<LibraryItem["id"], LibraryItem>;
}; };
// an object so that we can later add more properties to it without breaking, // an object so that we can later add more properties to it without breaking,
@@ -170,6 +171,7 @@ const createLibraryUpdate = (
const update: LibraryUpdate = { const update: LibraryUpdate = {
deletedItems: new Map<LibraryItem["id"], LibraryItem>(), deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
addedItems: new Map<LibraryItem["id"], LibraryItem>(), addedItems: new Map<LibraryItem["id"], LibraryItem>(),
updatedItems: new Map<LibraryItem["id"], LibraryItem>(),
}; };
for (const item of prevLibraryItems) { for (const item of prevLibraryItems) {
@@ -181,8 +183,11 @@ const createLibraryUpdate = (
const prevItemsMap = arrayToMap(prevLibraryItems); const prevItemsMap = arrayToMap(prevLibraryItems);
for (const item of nextLibraryItems) { for (const item of nextLibraryItems) {
if (!prevItemsMap.has(item.id)) { const prevItem = prevItemsMap.get(item.id);
if (!prevItem) {
update.addedItems.set(item.id, item); update.addedItems.set(item.id, item);
} else if (getLibraryItemHash(prevItem) !== getLibraryItemHash(item)) {
update.updatedItems.set(item.id, item);
} }
} }
@@ -192,6 +197,7 @@ const createLibraryUpdate = (
class Library { class Library {
/** latest libraryItems */ /** latest libraryItems */
private currLibraryItems: LibraryItems = []; private currLibraryItems: LibraryItems = [];
/** snapshot of library items since last onLibraryChange call */ /** snapshot of library items since last onLibraryChange call */
private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
@@ -585,12 +591,14 @@ class AdapterTransaction {
let lastSavedLibraryItemsHash = 0; let lastSavedLibraryItemsHash = 0;
let librarySaveCounter = 0; let librarySaveCounter = 0;
const getLibraryItemHash = (item: LibraryItem) => {
return `${item.id}:${item.name || ""}:${hashElementsVersion(item.elements)}`;
};
export const getLibraryItemsHash = (items: LibraryItems) => { export const getLibraryItemsHash = (items: LibraryItems) => {
return hashString( return hashString(
items items
.map((item) => { .map((item) => getLibraryItemHash(item))
return `${item.id}:${hashElementsVersion(item.elements)}`;
})
.sort() .sort()
.join(), .join(),
); );
@@ -640,6 +648,13 @@ const persistLibraryUpdate = async (
} }
} }
// replace existing items with their updated versions
if (update.updatedItems) {
for (const [id, item] of update.updatedItems) {
nextLibraryItemsMap.set(id, item);
}
}
const nextLibraryItems = addedItems.concat( const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()), Array.from(nextLibraryItemsMap.values()),
); );
+5
View File
@@ -6,6 +6,7 @@ import type { cleanAppStateForExport } from "../appState";
import type { import type {
AppState, AppState,
BinaryFiles, BinaryFiles,
LibraryItem,
LibraryItems, LibraryItems,
LibraryItems_anyVersion, LibraryItems_anyVersion,
} from "../types"; } from "../types";
@@ -59,3 +60,7 @@ export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
/** @deprecated v1 */ /** @deprecated v1 */
library?: LibraryItems; library?: LibraryItems;
} }
export type ExcalidrawLibraryIds = {
itemIds: LibraryItem["id"][];
};
+17 -8
View File
@@ -2,10 +2,10 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
distanceToElement,
doBoundsIntersect, doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds, getElementBounds,
getElementLineSegments,
getFreedrawOutlineAsSegments, getFreedrawOutlineAsSegments,
getFreedrawOutlinePoints, getFreedrawOutlinePoints,
intersectElementWithLineSegment, intersectElementWithLineSegment,
@@ -265,19 +265,28 @@ const eraserTest = (
} }
return false; return false;
} else if ( }
isArrowElement(element) ||
(isLineElement(element) && !element.polygon) const boundTextElement = getBoundTextElement(element, elementsMap);
) {
if (isArrowElement(element) || (isLineElement(element) && !element.polygon)) {
const tolerance = Math.max( const tolerance = Math.max(
element.strokeWidth, element.strokeWidth,
(element.strokeWidth * 2) / zoom, (element.strokeWidth * 2) / zoom,
); );
return distanceToElement(element, elementsMap, lastPoint) <= tolerance; // If the eraser movement is so fast that a large distance is covered
} // between the last two points, the distanceToElement miss, so we test
// agaist each segment of the linear element
const segments = getElementLineSegments(element, elementsMap);
for (const seg of segments) {
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
return true;
}
}
const boundTextElement = getBoundTextElement(element, elementsMap); return false;
}
return ( return (
intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true) intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
@@ -28,6 +28,7 @@ export const useLibraryItemSvg = (
id: LibraryItem["id"] | null, id: LibraryItem["id"] | null,
elements: LibraryItem["elements"] | undefined, elements: LibraryItem["elements"] | undefined,
svgCache: SvgCache, svgCache: SvgCache,
ref: React.RefObject<HTMLDivElement | null>,
): SVGSVGElement | undefined => { ): SVGSVGElement | undefined => {
const [svg, setSvg] = useState<SVGSVGElement>(); const [svg, setSvg] = useState<SVGSVGElement>();
@@ -62,6 +63,22 @@ export const useLibraryItemSvg = (
} }
}, [id, elements, svgCache, setSvg]); }, [id, elements, svgCache, setSvg]);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
if (svg) {
node.innerHTML = svg.outerHTML;
}
return () => {
node.innerHTML = "";
};
}, [svg, ref]);
return svg; return svg;
}; };
+2
View File
@@ -28,6 +28,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
excalidrawAPI, excalidrawAPI,
isCollaborating = false, isCollaborating = false,
onPointerUpdate, onPointerUpdate,
renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
langCode = defaultLang.code, langCode = defaultLang.code,
viewModeEnabled, viewModeEnabled,
@@ -120,6 +121,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
excalidrawAPI={excalidrawAPI} excalidrawAPI={excalidrawAPI}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
langCode={langCode} langCode={langCode}
viewModeEnabled={viewModeEnabled} viewModeEnabled={viewModeEnabled}

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