@@ -628,7 +646,7 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
- PanelComponent: ({ elements, appState, updateData, app }) => (
+ PanelComponent: ({ elements, appState, updateData, app, data }) => (
@@ -1317,7 +1365,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
- PanelComponent: ({ elements, appState, updateData, app }) => {
+ PanelComponent: ({ elements, appState, updateData, app, data }) => {
return (
@@ -1367,7 +1415,15 @@ export const actionChangeVerticalAlign = register({
) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
- onChange={(value) => updateData(value)}
+ onChange={(value) => {
+ withCaretPositionPreservation(
+ () => updateData(value),
+ appState.stylesPanelMode === "compact" ||
+ appState.stylesPanelMode === "mobile",
+ !!appState.editingTextElement,
+ data?.onPreventClose,
+ );
+ }}
/>
@@ -1616,6 +1672,25 @@ export const actionChangeArrowhead = register({
},
});
+export const actionChangeArrowProperties = register({
+ name: "changeArrowProperties",
+ label: "Change arrow properties",
+ trackEvent: false,
+ perform: (elements, appState, value, app) => {
+ // This action doesn't perform any changes directly
+ // It's just a container for the arrow type and arrowhead actions
+ return false;
+ },
+ PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
+ return (
+
+ {renderAction("changeArrowhead")}
+ {renderAction("changeArrowType")}
+
+ );
+ },
+});
+
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx
index 62a6aa411f..1269ed23f6 100644
--- a/packages/excalidraw/actions/actionZindex.tsx
+++ b/packages/excalidraw/actions/actionZindex.tsx
@@ -1,4 +1,4 @@
-import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
+import { KEYS, CODES, isDarwin } from "@excalidraw/common";
import {
moveOneLeft,
@@ -16,6 +16,7 @@ import {
SendToBackIcon,
} from "../components/icons";
import { t } from "../i18n";
+import { getShortcutKey } from "../shortcut";
import { register } from "./register";
diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts
index f37747aebd..6b888e92d3 100644
--- a/packages/excalidraw/actions/index.ts
+++ b/packages/excalidraw/actions/index.ts
@@ -18,6 +18,7 @@ export {
actionChangeFontFamily,
actionChangeTextAlign,
actionChangeVerticalAlign,
+ actionChangeArrowProperties,
} from "./actionProperties";
export {
@@ -43,11 +44,7 @@ export {
} from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
-export {
- actionToggleCanvasMenu,
- actionToggleEditMenu,
- actionShortcuts,
-} from "./actionMenu";
+export { actionShortcuts } from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts
index 1a13f1703c..ca593c3402 100644
--- a/packages/excalidraw/actions/shortcuts.ts
+++ b/packages/excalidraw/actions/shortcuts.ts
@@ -1,8 +1,9 @@
-import { isDarwin, getShortcutKey } from "@excalidraw/common";
+import { isDarwin } from "@excalidraw/common";
import type { SubtypeOf } from "@excalidraw/common/utility-types";
import { t } from "../i18n";
+import { getShortcutKey } from "../shortcut";
import type { ActionName } from "./types";
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
index e6f3631263..d533294d39 100644
--- a/packages/excalidraw/actions/types.ts
+++ b/packages/excalidraw/actions/types.ts
@@ -69,10 +69,9 @@ export type ActionName =
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
+ | "changeArrowProperties"
| "changeOpacity"
| "changeFontSize"
- | "toggleCanvasMenu"
- | "toggleEditMenu"
| "undo"
| "redo"
| "finalize"
diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts
index 029b2cc61d..52535e9a36 100644
--- a/packages/excalidraw/appState.ts
+++ b/packages/excalidraw/appState.ts
@@ -55,6 +55,10 @@ export const getDefaultAppState = (): Omit<
fromSelection: false,
lastActiveTool: null,
},
+ preferredSelectionTool: {
+ type: "selection",
+ initialized: false,
+ },
penMode: false,
penDetected: false,
errorMessage: null,
@@ -123,6 +127,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
+ stylesPanelMode: "full",
};
};
@@ -175,6 +180,7 @@ const APP_STATE_STORAGE_CONF = (<
editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
+ preferredSelectionTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
@@ -248,6 +254,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
+ stylesPanelMode: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <
diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts
index 770bcc90e7..2115c3eff2 100644
--- a/packages/excalidraw/clipboard.test.ts
+++ b/packages/excalidraw/clipboard.test.ts
@@ -1,6 +1,7 @@
import {
createPasteEvent,
parseClipboard,
+ parseDataTransferEvent,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
text = "123";
clipboardData = await parseClipboard(
- createPasteEvent({ types: { "text/plain": text } }),
+ await parseDataTransferEvent(
+ createPasteEvent({ types: { "text/plain": text } }),
+ ),
);
expect(clipboardData.text).toBe(text);
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
text = "[123]";
clipboardData = await parseClipboard(
- createPasteEvent({ types: { "text/plain": text } }),
+ await parseDataTransferEvent(
+ createPasteEvent({ types: { "text/plain": text } }),
+ ),
);
expect(clipboardData.text).toBe(text);
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
- createPasteEvent({ types: { "text/plain": text } }),
+ await parseDataTransferEvent(
+ createPasteEvent({ types: { "text/plain": text } }),
+ ),
);
expect(clipboardData.text).toBe(text);
});
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/plain": json,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/plain": json,
+ },
+ }),
+ ),
);
expect(clipboardData.elements).toEqual([rect]);
});
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": json,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": json,
+ },
+ }),
+ ),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `
${json}
`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `
${json}
`,
+ },
+ }),
+ ),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `

`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `

`,
+ },
+ }),
+ ),
);
expect(clipboardData.mixedContent).toEqual([
{
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `

`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `

`,
+ },
+ }),
+ ),
);
expect(clipboardData.mixedContent).toEqual([
{
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
it("should parse text content alongside
`src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `hello my friend!`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `hello my friend!`,
+ },
+ }),
+ ),
);
expect(clipboardData.mixedContent).toEqual([
{
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/plain": `a b
- 1 2
- 4 5
- 7 10`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/plain": `a b
+ 1 2
+ 4 5
+ 7 10`,
+ },
+ }),
+ ),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `a b
- 1 2
- 4 5
- 7 10`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `a b
+ 1 2
+ 4 5
+ 7 10`,
+ },
+ }),
+ ),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `
-
-
-
- `,
- "text/plain": `a b
- 1 2
- 4 5
- 7 10`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `
+
+
+
+ `,
+ "text/plain": `a b
+ 1 2
+ 4 5
+ 7 10`,
+ },
+ }),
+ ),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts
index 99b7d41f4a..ae532a6c27 100644
--- a/packages/excalidraw/clipboard.ts
+++ b/packages/excalidraw/clipboard.ts
@@ -5,6 +5,7 @@ import {
arrayToMap,
isMemberOf,
isPromiseLike,
+ EVENT,
} from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element";
@@ -16,15 +17,26 @@ import {
import { getContainingFrame } from "@excalidraw/element";
+import type { ValueOf } from "@excalidraw/common/utility-types";
+
+import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors";
-import { createFile, isSupportedImageFileType } from "./data/blob";
+import {
+ createFile,
+ getFileHandle,
+ isSupportedImageFileType,
+ normalizeFile,
+} from "./data/blob";
+
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
+import type { FileSystemHandle } from "./data/filesystem";
+
import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types";
@@ -92,7 +104,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided");
}
- const event = new ClipboardEvent("paste", {
+ const event = new ClipboardEvent(EVENT.PASTE, {
clipboardData: new DataTransfer(),
});
@@ -101,10 +113,11 @@ export const createPasteEvent = ({
if (typeof value !== "string") {
files = files || [];
files.push(value);
+ event.clipboardData?.items.add(value);
continue;
}
try {
- event.clipboardData?.setData(type, value);
+ event.clipboardData?.items.add(value, type);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
@@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
return result;
}
-const maybeParseHTMLPaste = (
- event: ClipboardEvent,
+const maybeParseHTMLDataItem = (
+ dataItem: ParsedDataTransferItemType,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
- const html = event.clipboardData?.getData(MIME_TYPES.html);
-
- if (!html) {
- return null;
- }
+ const html = dataItem.value;
try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEventTextData = async (
- event: ClipboardEvent,
+ dataList: ParsedDataTranferList,
isPlainPaste = false,
): Promise => {
try {
- const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
+ const htmlItem = dataList.findByType(MIME_TYPES.html);
+
+ const mixedContent =
+ !isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
- event.clipboardData?.getData(MIME_TYPES.text) ||
+ dataList.getData(MIME_TYPES.text) ??
mixedContent.value
.map((item) => item.value)
.join("\n")
@@ -354,23 +366,156 @@ const parseClipboardEventTextData = async (
return mixedContent;
}
- const text = event.clipboardData?.getData(MIME_TYPES.text);
-
- return { type: "text", value: (text || "").trim() };
+ return {
+ type: "text",
+ value: (dataList.getData(MIME_TYPES.text) || "").trim(),
+ };
} catch {
return { type: "text", value: "" };
}
};
+type AllowedParsedDataTransferItem =
+ | {
+ type: ValueOf;
+ kind: "file";
+ file: File;
+ fileHandle: FileSystemHandle | null;
+ }
+ | { type: ValueOf; kind: "string"; value: string };
+
+type ParsedDataTransferItem =
+ | {
+ type: string;
+ kind: "file";
+ file: File;
+ fileHandle: FileSystemHandle | null;
+ }
+ | { type: string; kind: "string"; value: string };
+
+type ParsedDataTransferItemType<
+ T extends AllowedParsedDataTransferItem["type"],
+> = AllowedParsedDataTransferItem & { type: T };
+
+export type ParsedDataTransferFile = Extract<
+ AllowedParsedDataTransferItem,
+ { kind: "file" }
+>;
+
+type ParsedDataTranferList = ParsedDataTransferItem[] & {
+ /**
+ * Only allows filtering by known `string` data types, since `file`
+ * types can have multiple items of the same type (e.g. multiple image files)
+ * unlike `string` data transfer items.
+ */
+ findByType: typeof findDataTransferItemType;
+ /**
+ * Only allows filtering by known `string` data types, since `file`
+ * types can have multiple items of the same type (e.g. multiple image files)
+ * unlike `string` data transfer items.
+ */
+ getData: typeof getDataTransferItemData;
+ getFiles: typeof getDataTransferFiles;
+};
+
+const findDataTransferItemType = function <
+ T extends ValueOf,
+>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType | null {
+ return (
+ this.find(
+ (item): item is ParsedDataTransferItemType => item.type === type,
+ ) || null
+ );
+};
+const getDataTransferItemData = function <
+ T extends ValueOf,
+>(
+ this: ParsedDataTranferList,
+ type: T,
+):
+ | ParsedDataTransferItemType>["value"]
+ | null {
+ const item = this.find(
+ (
+ item,
+ ): item is ParsedDataTransferItemType> =>
+ item.type === type,
+ );
+
+ return item?.value ?? null;
+};
+
+const getDataTransferFiles = function (
+ this: ParsedDataTranferList,
+): ParsedDataTransferFile[] {
+ return this.filter(
+ (item): item is ParsedDataTransferFile => item.kind === "file",
+ );
+};
+
+export const parseDataTransferEvent = async (
+ event: ClipboardEvent | DragEvent | React.DragEvent,
+): Promise => {
+ let items: DataTransferItemList | undefined = undefined;
+
+ if (isClipboardEvent(event)) {
+ items = event.clipboardData?.items;
+ } else {
+ const dragEvent = event;
+ items = dragEvent.dataTransfer?.items;
+ }
+
+ const dataItems = (
+ await Promise.all(
+ Array.from(items || []).map(
+ async (item): Promise => {
+ if (item.kind === "file") {
+ let file = item.getAsFile();
+ if (file) {
+ const fileHandle = await getFileHandle(item);
+ file = await normalizeFile(file);
+ return {
+ type: file.type,
+ kind: "file",
+ file,
+ fileHandle,
+ };
+ }
+ } else if (item.kind === "string") {
+ const { type } = item;
+ let value: string;
+ if ("clipboardData" in event && event.clipboardData) {
+ value = event.clipboardData?.getData(type);
+ } else {
+ value = await new Promise((resolve) => {
+ item.getAsString((str) => resolve(str));
+ });
+ }
+ return { type, kind: "string", value };
+ }
+
+ return null;
+ },
+ ),
+ )
+ ).filter((data): data is ParsedDataTransferItem => data != null);
+
+ return Object.assign(dataItems, {
+ findByType: findDataTransferItemType,
+ getData: getDataTransferItemData,
+ getFiles: getDataTransferFiles,
+ });
+};
+
/**
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
- event: ClipboardEvent,
+ dataList: ParsedDataTranferList,
isPlainPaste = false,
): Promise => {
const parsedEventData = await parseClipboardEventTextData(
- event,
+ dataList,
isPlainPaste,
);
@@ -519,3 +664,14 @@ const copyTextViaExecCommand = (text: string | null) => {
return success;
};
+
+export const isClipboardEvent = (
+ event: React.SyntheticEvent | Event,
+): event is ClipboardEvent => {
+ /** not using instanceof ClipboardEvent due to tests (jsdom) */
+ return (
+ event.type === EVENT.PASTE ||
+ event.type === EVENT.COPY ||
+ event.type === EVENT.CUT
+ );
+};
diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss
index 5826628de1..f97f3c7b6f 100644
--- a/packages/excalidraw/components/Actions.scss
+++ b/packages/excalidraw/components/Actions.scss
@@ -91,3 +91,118 @@
}
}
}
+
+.compact-shape-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-height: calc(100vh - 200px);
+ overflow-y: auto;
+ padding: 0.5rem;
+
+ .compact-action-item {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 2.5rem;
+ pointer-events: auto;
+
+ --default-button-size: 2rem;
+
+ .compact-action-button {
+ width: var(--mobile-action-button-size);
+ height: var(--mobile-action-button-size);
+ border: none;
+ border-radius: var(--border-radius-lg);
+ color: var(--color-on-surface);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+
+ background: var(--mobile-action-button-bg);
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ flex: 0 0 auto;
+ }
+
+ &.active {
+ background: var(
+ --color-surface-primary-container,
+ var(--mobile-action-button-bg)
+ );
+ }
+ }
+
+ .compact-popover-content {
+ .popover-section {
+ margin-bottom: 1rem;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ .popover-section-title {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ margin-bottom: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .buttonList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ }
+ }
+ }
+ }
+
+ .ToolIcon {
+ .ToolIcon__icon {
+ width: var(--mobile-action-button-size);
+ height: var(--mobile-action-button-size);
+
+ background: var(--mobile-action-button-bg);
+
+ &:hover {
+ background-color: transparent;
+ }
+ }
+ }
+}
+
+.compact-shape-actions-island {
+ width: fit-content;
+ overflow-x: hidden;
+}
+
+.mobile-shape-actions {
+ z-index: 999;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ width: 100%;
+ background: transparent;
+ border-radius: var(--border-radius-lg);
+ box-shadow: none;
+ overflow: none;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.shape-actions-theme-scope {
+ --button-border: transparent;
+ --button-bg: var(--color-surface-mid);
+}
+
+:root.theme--dark .shape-actions-theme-scope {
+ --button-hover-bg: #363541;
+ --button-bg: var(--color-surface-high);
+}
diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx
index 5c9d59ada3..48ec4dc9a2 100644
--- a/packages/excalidraw/components/Actions.tsx
+++ b/packages/excalidraw/components/Actions.tsx
@@ -1,5 +1,6 @@
import clsx from "clsx";
-import { useState } from "react";
+import { useRef, useState } from "react";
+import * as Popover from "@radix-ui/react-popover";
import {
CLASSES,
@@ -11,18 +12,16 @@ import {
import {
shouldAllowVerticalAlign,
suppportsHorizontalAlign,
-} from "@excalidraw/element";
-
-import {
hasBoundTextElement,
isElbowArrow,
isImageElement,
isLinearElement,
isTextElement,
+ isArrowElement,
+ hasStrokeColor,
+ toolIsArrow,
} from "@excalidraw/element";
-import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
-
import type {
ExcalidrawElement,
ExcalidrawElementType,
@@ -46,15 +45,21 @@ import {
hasStrokeWidth,
} from "../scene";
-import { SHAPES } from "./shapes";
+import { getFormValue } from "../actions/actionProperties";
+
+import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
+
+import { getToolbarTools } from "./shapes";
import "./Actions.scss";
-import { useDevice } from "./App";
+import { useDevice, useExcalidrawContainer } from "./App";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
+import { ToolPopover } from "./ToolPopover";
import { Tooltip } from "./Tooltip";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
+import { PropertiesPopover } from "./PropertiesPopover";
import {
EmbedIcon,
extraToolsIcon,
@@ -63,11 +68,32 @@ import {
laserPointerToolIcon,
MagicIcon,
LassoIcon,
+ sharpArrowIcon,
+ roundArrowIcon,
+ elbowArrowIcon,
+ TextSizeIcon,
+ adjustmentsIcon,
+ DotsHorizontalIcon,
+ SelectionIcon,
} from "./icons";
-import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
+import { Island } from "./Island";
+
+import type {
+ AppClassProperties,
+ AppProps,
+ UIAppState,
+ Zoom,
+ AppState,
+} from "../types";
import type { ActionManager } from "../actions/manager";
+// Common CSS class combinations
+const PROPERTIES_CLASSES = clsx([
+ CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
+ "properties-content",
+]);
+
export const canChangeStrokeColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
@@ -280,22 +306,761 @@ export const SelectedShapeActions = ({
);
};
+const CombinedShapeProperties = ({
+ appState,
+ renderAction,
+ setAppState,
+ targetElements,
+ container,
+}: {
+ targetElements: ExcalidrawElement[];
+ appState: UIAppState;
+ renderAction: ActionManager["renderAction"];
+ setAppState: React.Component["setState"];
+ container: HTMLDivElement | null;
+}) => {
+ const showFillIcons =
+ (hasBackground(appState.activeTool.type) &&
+ !isTransparent(appState.currentItemBackgroundColor)) ||
+ targetElements.some(
+ (element) =>
+ hasBackground(element.type) && !isTransparent(element.backgroundColor),
+ );
+
+ const shouldShowCombinedProperties =
+ targetElements.length > 0 ||
+ (appState.activeTool.type !== "selection" &&
+ appState.activeTool.type !== "eraser" &&
+ appState.activeTool.type !== "hand" &&
+ appState.activeTool.type !== "laser" &&
+ appState.activeTool.type !== "lasso");
+ const isOpen = appState.openPopup === "compactStrokeStyles";
+
+ if (!shouldShowCombinedProperties) {
+ return null;
+ }
+
+ return (
+
+
{
+ if (open) {
+ setAppState({ openPopup: "compactStrokeStyles" });
+ } else {
+ setAppState({ openPopup: null });
+ }
+ }}
+ >
+
+
+
+ {isOpen && (
+ {}}
+ >
+
+ {showFillIcons && renderAction("changeFillStyle")}
+ {(hasStrokeWidth(appState.activeTool.type) ||
+ targetElements.some((element) =>
+ hasStrokeWidth(element.type),
+ )) &&
+ renderAction("changeStrokeWidth")}
+ {(hasStrokeStyle(appState.activeTool.type) ||
+ targetElements.some((element) =>
+ hasStrokeStyle(element.type),
+ )) && (
+ <>
+ {renderAction("changeStrokeStyle")}
+ {renderAction("changeSloppiness")}
+ >
+ )}
+ {(canChangeRoundness(appState.activeTool.type) ||
+ targetElements.some((element) =>
+ canChangeRoundness(element.type),
+ )) &&
+ renderAction("changeRoundness")}
+ {renderAction("changeOpacity")}
+
+
+ )}
+
+
+ );
+};
+
+const CombinedArrowProperties = ({
+ appState,
+ renderAction,
+ setAppState,
+ targetElements,
+ container,
+ app,
+}: {
+ targetElements: ExcalidrawElement[];
+ appState: UIAppState;
+ renderAction: ActionManager["renderAction"];
+ setAppState: React.Component["setState"];
+ container: HTMLDivElement | null;
+ app: AppClassProperties;
+}) => {
+ const showShowArrowProperties =
+ toolIsArrow(appState.activeTool.type) ||
+ targetElements.some((element) => toolIsArrow(element.type));
+ const isOpen = appState.openPopup === "compactArrowProperties";
+
+ if (!showShowArrowProperties) {
+ return null;
+ }
+
+ return (
+
+
{
+ if (open) {
+ setAppState({ openPopup: "compactArrowProperties" });
+ } else {
+ setAppState({ openPopup: null });
+ }
+ }}
+ >
+
+
+
+ {isOpen && (
+ {}}
+ >
+ {renderAction("changeArrowProperties")}
+
+ )}
+
+
+ );
+};
+
+const CombinedTextProperties = ({
+ appState,
+ renderAction,
+ setAppState,
+ targetElements,
+ container,
+ elementsMap,
+}: {
+ appState: UIAppState;
+ renderAction: ActionManager["renderAction"];
+ setAppState: React.Component["setState"];
+ targetElements: ExcalidrawElement[];
+ container: HTMLDivElement | null;
+ elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
+}) => {
+ const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
+ const isOpen = appState.openPopup === "compactTextProperties";
+
+ return (
+
+
{
+ if (open) {
+ if (appState.editingTextElement) {
+ saveCaretPosition();
+ }
+ setAppState({ openPopup: "compactTextProperties" });
+ } else {
+ setAppState({ openPopup: null });
+ if (appState.editingTextElement) {
+ restoreCaretPosition();
+ }
+ }
+ }}
+ >
+
+
+
+ {appState.openPopup === "compactTextProperties" && (
+ {
+ // Refocus text editor when popover closes with caret restoration
+ if (appState.editingTextElement) {
+ restoreCaretPosition();
+ }
+ }}
+ >
+
+ {(appState.activeTool.type === "text" ||
+ targetElements.some(isTextElement)) &&
+ renderAction("changeFontSize")}
+ {(appState.activeTool.type === "text" ||
+ suppportsHorizontalAlign(targetElements, elementsMap)) &&
+ renderAction("changeTextAlign")}
+ {shouldAllowVerticalAlign(targetElements, elementsMap) &&
+ renderAction("changeVerticalAlign")}
+
+
+ )}
+
+
+ );
+};
+
+const CombinedExtraActions = ({
+ appState,
+ renderAction,
+ targetElements,
+ setAppState,
+ container,
+ app,
+ showDuplicate,
+ showDelete,
+}: {
+ appState: UIAppState;
+ targetElements: ExcalidrawElement[];
+ renderAction: ActionManager["renderAction"];
+ setAppState: React.Component["setState"];
+ container: HTMLDivElement | null;
+ app: AppClassProperties;
+ showDuplicate?: boolean;
+ showDelete?: boolean;
+}) => {
+ const isEditingTextOrNewElement = Boolean(
+ appState.editingTextElement || appState.newElement,
+ );
+ const showCropEditorAction =
+ !appState.croppingElementId &&
+ targetElements.length === 1 &&
+ isImageElement(targetElements[0]);
+ const showLinkIcon = targetElements.length === 1;
+ const showAlignActions = alignActionsPredicate(appState, app);
+ let isSingleElementBoundContainer = false;
+ if (
+ targetElements.length === 2 &&
+ (hasBoundTextElement(targetElements[0]) ||
+ hasBoundTextElement(targetElements[1]))
+ ) {
+ isSingleElementBoundContainer = true;
+ }
+
+ const isRTL = document.documentElement.getAttribute("dir") === "rtl";
+ const isOpen = appState.openPopup === "compactOtherProperties";
+
+ if (isEditingTextOrNewElement || targetElements.length === 0) {
+ return null;
+ }
+
+ return (
+
+
{
+ if (open) {
+ setAppState({ openPopup: "compactOtherProperties" });
+ } else {
+ setAppState({ openPopup: null });
+ }
+ }}
+ >
+
+
+
+ {isOpen && (
+ {}}
+ >
+
+
+
+
+ {renderAction("sendToBack")}
+ {renderAction("sendBackward")}
+ {renderAction("bringForward")}
+ {renderAction("bringToFront")}
+
+
+
+ {showAlignActions && !isSingleElementBoundContainer && (
+
+
+
+ {isRTL ? (
+ <>
+ {renderAction("alignRight")}
+ {renderAction("alignHorizontallyCentered")}
+ {renderAction("alignLeft")}
+ >
+ ) : (
+ <>
+ {renderAction("alignLeft")}
+ {renderAction("alignHorizontallyCentered")}
+ {renderAction("alignRight")}
+ >
+ )}
+ {targetElements.length > 2 &&
+ renderAction("distributeHorizontally")}
+ {/* breaks the row ˇˇ */}
+
+
+ {renderAction("alignTop")}
+ {renderAction("alignVerticallyCentered")}
+ {renderAction("alignBottom")}
+ {targetElements.length > 2 &&
+ renderAction("distributeVertically")}
+
+
+
+ )}
+
+
+
+ {renderAction("group")}
+ {renderAction("ungroup")}
+ {showLinkIcon && renderAction("hyperlink")}
+ {showCropEditorAction && renderAction("cropEditor")}
+ {showDuplicate && renderAction("duplicateSelection")}
+ {showDelete && renderAction("deleteSelectedElements")}
+
+
+
+
+ )}
+
+
+ );
+};
+
+const LinearEditorAction = ({
+ appState,
+ renderAction,
+ targetElements,
+}: {
+ appState: UIAppState;
+ targetElements: ExcalidrawElement[];
+ renderAction: ActionManager["renderAction"];
+}) => {
+ const showLineEditorAction =
+ !appState.selectedLinearElement?.isEditing &&
+ targetElements.length === 1 &&
+ isLinearElement(targetElements[0]) &&
+ !isElbowArrow(targetElements[0]);
+
+ if (!showLineEditorAction) {
+ return null;
+ }
+
+ return (
+
+ {renderAction("toggleLinearEditor")}
+
+ );
+};
+
+export const CompactShapeActions = ({
+ appState,
+ elementsMap,
+ renderAction,
+ app,
+ setAppState,
+}: {
+ appState: UIAppState;
+ elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
+ renderAction: ActionManager["renderAction"];
+ app: AppClassProperties;
+ setAppState: React.Component["setState"];
+}) => {
+ const targetElements = getTargetElements(elementsMap, appState);
+ const { container } = useExcalidrawContainer();
+
+ const isEditingTextOrNewElement = Boolean(
+ appState.editingTextElement || appState.newElement,
+ );
+
+ const showLineEditorAction =
+ !appState.selectedLinearElement?.isEditing &&
+ targetElements.length === 1 &&
+ isLinearElement(targetElements[0]) &&
+ !isElbowArrow(targetElements[0]);
+
+ return (
+
+ {/* Stroke Color */}
+ {canChangeStrokeColor(appState, targetElements) && (
+
+ {renderAction("changeStrokeColor")}
+
+ )}
+
+ {/* Background Color */}
+ {canChangeBackgroundColor(appState, targetElements) && (
+
+ {renderAction("changeBackgroundColor")}
+
+ )}
+
+
+
+
+ {/* Linear Editor */}
+ {showLineEditorAction && (
+
+ {renderAction("toggleLinearEditor")}
+
+ )}
+
+ {/* Text Properties */}
+ {(appState.activeTool.type === "text" ||
+ targetElements.some(isTextElement)) && (
+ <>
+
+ {renderAction("changeFontFamily")}
+
+
+ >
+ )}
+
+ {/* Dedicated Copy Button */}
+ {!isEditingTextOrNewElement && targetElements.length > 0 && (
+
+ {renderAction("duplicateSelection")}
+
+ )}
+
+ {/* Dedicated Delete Button */}
+ {!isEditingTextOrNewElement && targetElements.length > 0 && (
+
+ {renderAction("deleteSelectedElements")}
+
+ )}
+
+
+
+ );
+};
+
+export const MobileShapeActions = ({
+ appState,
+ elementsMap,
+ renderAction,
+ app,
+ setAppState,
+}: {
+ appState: UIAppState;
+ elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
+ renderAction: ActionManager["renderAction"];
+ app: AppClassProperties;
+ setAppState: React.Component["setState"];
+}) => {
+ const targetElements = getTargetElements(elementsMap, appState);
+ const { container } = useExcalidrawContainer();
+ const mobileActionsRef = useRef(null);
+
+ const ACTIONS_WIDTH =
+ mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0;
+
+ // 7 actions + 2 for undo/redo
+ const MIN_ACTIONS = 9;
+
+ const GAP = 6;
+ const WIDTH = 32;
+
+ const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP;
+
+ const ADDITIONAL_WIDTH = WIDTH + GAP;
+
+ const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH;
+ const showDuplicateOutside =
+ ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
+
+ return (
+
+
+ {canChangeStrokeColor(appState, targetElements) && (
+
+ {renderAction("changeStrokeColor")}
+
+ )}
+ {canChangeBackgroundColor(appState, targetElements) && (
+
+ {renderAction("changeBackgroundColor")}
+
+ )}
+
+ {/* Combined Arrow Properties */}
+
+ {/* Linear Editor */}
+
+ {/* Text Properties */}
+ {(appState.activeTool.type === "text" ||
+ targetElements.some(isTextElement)) && (
+ <>
+
+ {renderAction("changeFontFamily")}
+
+
+ >
+ )}
+
+ {/* Combined Other Actions */}
+
+
+
+
{renderAction("undo")}
+
{renderAction("redo")}
+ {showDuplicateOutside && (
+
+ {renderAction("duplicateSelection")}
+
+ )}
+ {showDeleteOutside && (
+
+ {renderAction("deleteSelectedElements")}
+
+ )}
+
+
+ );
+};
+
export const ShapesSwitcher = ({
activeTool,
- appState,
+ setAppState,
app,
UIOptions,
}: {
activeTool: UIAppState["activeTool"];
- appState: UIAppState;
+ setAppState: React.Component["setState"];
app: AppClassProperties;
UIOptions: AppProps["UIOptions"];
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
+ const SELECTION_TOOLS = [
+ {
+ type: "selection",
+ icon: SelectionIcon,
+ title: capitalizeString(t("toolBar.selection")),
+ },
+ {
+ type: "lasso",
+ icon: LassoIcon,
+ title: capitalizeString(t("toolBar.lasso")),
+ },
+ ] as const;
+
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
- const lassoToolSelected = activeTool.type === "lasso";
+ const lassoToolSelected =
+ app.state.stylesPanelMode === "full" &&
+ activeTool.type === "lasso" &&
+ app.state.preferredSelectionTool.type !== "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
@@ -303,63 +1068,102 @@ export const ShapesSwitcher = ({
return (
<>
- {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
- if (
- UIOptions.tools?.[
- value as Extract
- ] === false
- ) {
- return null;
- }
+ {getToolbarTools(app).map(
+ ({ value, icon, key, numericKey, fillable }, index) => {
+ if (
+ UIOptions.tools?.[
+ value as Extract<
+ typeof value,
+ keyof AppProps["UIOptions"]["tools"]
+ >
+ ] === false
+ ) {
+ return null;
+ }
- const label = t(`toolBar.${value}`);
- const letter =
- key && capitalizeString(typeof key === "string" ? key : key[0]);
- const shortcut = letter
- ? `${letter} ${t("helpDialog.or")} ${numericKey}`
- : `${numericKey}`;
-
- return (
- {
- if (!appState.penDetected && pointerType === "pen") {
- app.togglePenMode(true);
- }
-
- if (value === "selection") {
- if (appState.activeTool.type === "selection") {
- app.setActiveTool({ type: "lasso" });
- } else {
- app.setActiveTool({ type: "selection" });
+ const label = t(`toolBar.${value}`);
+ const letter =
+ key && capitalizeString(typeof key === "string" ? key : key[0]);
+ const shortcut = letter
+ ? `${letter} ${t("helpDialog.or")} ${numericKey}`
+ : `${numericKey}`;
+ // when in compact styles panel mode (tablet)
+ // use a ToolPopover for selection/lasso toggle as well
+ if (
+ (value === "selection" || value === "lasso") &&
+ app.state.stylesPanelMode === "compact"
+ ) {
+ return (
+ {
+ if (type === "selection" || type === "lasso") {
+ app.setActiveTool({ type });
+ setAppState({
+ preferredSelectionTool: { type, initialized: true },
+ });
+ }
+ }}
+ displayedOption={
+ SELECTION_TOOLS.find(
+ (tool) =>
+ tool.type === app.state.preferredSelectionTool.type,
+ ) || SELECTION_TOOLS[0]
}
- }
- }}
- onChange={({ pointerType }) => {
- if (appState.activeTool.type !== value) {
- trackEvent("toolbar", value, "ui");
- }
- if (value === "image") {
- app.setActiveTool({
- type: value,
- });
- } else {
- app.setActiveTool({ type: value });
- }
- }}
- />
- );
- })}
+ fillable={activeTool.type === "selection"}
+ />
+ );
+ }
+
+ return (
+ {
+ if (!app.state.penDetected && pointerType === "pen") {
+ app.togglePenMode(true);
+ }
+
+ if (value === "selection") {
+ if (app.state.activeTool.type === "selection") {
+ app.setActiveTool({ type: "lasso" });
+ } else {
+ app.setActiveTool({ type: "selection" });
+ }
+ }
+ }}
+ onChange={({ pointerType }) => {
+ if (app.state.activeTool.type !== value) {
+ trackEvent("toolbar", value, "ui");
+ }
+ if (value === "image") {
+ app.setActiveTool({
+ type: value,
+ });
+ } else {
+ app.setActiveTool({ type: value });
+ }
+ }}
+ />
+ );
+ },
+ )}
@@ -374,7 +1178,10 @@ export const ShapesSwitcher = ({
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
- onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
+ onToggle={() => {
+ setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen);
+ setAppState({ openMenu: null, openPopup: null });
+ }}
title={t("toolBar.extraTools")}
>
{frameToolSelected
@@ -418,14 +1225,16 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
- app.setActiveTool({ type: "lasso" })}
- icon={LassoIcon}
- data-testid="toolbar-lasso"
- selected={lassoToolSelected}
- >
- {t("toolBar.lasso")}
-
+ {app.state.stylesPanelMode === "full" && (
+ app.setActiveTool({ type: "lasso" })}
+ icon={LassoIcon}
+ data-testid="toolbar-lasso"
+ selected={lassoToolSelected}
+ >
+ {t("toolBar.lasso")}
+
+ )}
Generate
@@ -438,16 +1247,14 @@ export const ShapesSwitcher = ({
{t("toolBar.mermaidToExcalidraw")}
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
- <>
- app.onMagicframeToolSelect()}
- icon={MagicIcon}
- data-testid="toolbar-magicframe"
- >
- {t("toolBar.magicframe")}
- AI
-
- >
+ app.onMagicframeToolSelect()}
+ icon={MagicIcon}
+ data-testid="toolbar-magicframe"
+ >
+ {t("toolBar.magicframe")}
+ AI
+
)}
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index cb40137d8d..0f8fd85e0f 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -41,9 +41,6 @@ import {
LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES,
- MQ_MAX_HEIGHT_LANDSCAPE,
- MQ_MAX_WIDTH_LANDSCAPE,
- MQ_MAX_WIDTH_PORTRAIT,
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
POINTER_BUTTON,
ROUNDNESS,
@@ -83,7 +80,6 @@ import {
wrapEvent,
updateObject,
updateActiveTool,
- getShortcutKey,
isTransparent,
easeToValuesRAF,
muteFSAbortError,
@@ -101,6 +97,13 @@ import {
CLASSES,
Emitter,
MINIMUM_ARROW_SIZE,
+ DOUBLE_TAP_POSITION_THRESHOLD,
+ isMobileOrTablet,
+ MQ_MAX_MOBILE,
+ MQ_MIN_TABLET,
+ MQ_MAX_TABLET,
+ MQ_MAX_HEIGHT_LANDSCAPE,
+ MQ_MAX_WIDTH_LANDSCAPE,
} from "@excalidraw/common";
import {
@@ -169,7 +172,7 @@ import {
getContainerElement,
isValidTextContainer,
redrawTextBoundingBox,
- shouldShowBoundingBox,
+ hasBoundingBox,
getFrameChildren,
isCursorInFrame,
addElementsToFrame,
@@ -233,6 +236,9 @@ import {
hitElementBoundingBox,
isLineElement,
isSimpleArrow,
+ StoreDelta,
+ type ApplyToOptions,
+ positionElementsOnGrid,
} from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -259,6 +265,7 @@ import type {
MagicGenerationData,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
+ SceneElementsMap,
} from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -318,7 +325,13 @@ import {
isEraserActive,
isHandToolActive,
} from "../appState";
-import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
+import {
+ copyTextToSystemClipboard,
+ parseClipboard,
+ parseDataTransferEvent,
+ type ParsedDataTransferFile,
+} from "../clipboard";
+
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
@@ -340,7 +353,6 @@ import {
generateIdFromFile,
getDataURL,
getDataURL_sync,
- getFileFromEvent,
ImageURLToFile,
isImageFileHandle,
isSupportedImageFile,
@@ -398,6 +410,8 @@ import {
} from "../scene/scrollConstraints";
import { EraserTrail } from "../eraser";
+import { getShortcutKey } from "../shortcut";
+
import ConvertElementTypePopup, {
getConversionTypeFromElements,
convertElementTypePopupAtom,
@@ -427,12 +441,14 @@ import { findShapeByKey } from "./shapes";
import UnlockPopup from "./UnlockPopup";
+import type { ExcalidrawLibraryIds } from "../data/types";
+
import type {
RenderInteractiveSceneCallback,
ScrollBars,
} from "../scene/types";
-import type { PastedMixedContent } from "../clipboard";
+import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
@@ -535,6 +551,7 @@ export const useExcalidrawActionManager = () =>
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
+let firstTapPosition: { x: number; y: number } | null = null;
let isHoldingSpace: boolean = false;
let isPanning: boolean = false;
let isDraggingScrollBar: boolean = false;
@@ -710,6 +727,7 @@ class App extends React.Component {
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
+ applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
@@ -1519,7 +1537,7 @@ class App extends React.Component {
public render() {
const selectedElements = this.scene.getSelectedElements(this.state);
- const { renderTopRightUI, renderCustomStats } = this.props;
+ const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
const sceneNonce = this.scene.getSceneNonce();
const { elementsMap, visibleElements } =
@@ -1605,6 +1623,7 @@ class App extends React.Component {
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code}
+ renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
@@ -1616,7 +1635,8 @@ class App extends React.Component {
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
- this.state.activeTool.type === "selection" &&
+ this.state.activeTool.type ===
+ this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
@@ -2356,7 +2376,19 @@ class App extends React.Component {
},
};
}
- const scene = restore(initialData, null, null, { repairBindings: true });
+ const scene = restore(initialData, null, null, {
+ repairBindings: true,
+ deleteInvisibleElements: true,
+ });
+ const activeTool = scene.appState.activeTool;
+
+ if (!scene.appState.preferredSelectionTool.initialized) {
+ scene.appState.preferredSelectionTool = {
+ type: this.device.editor.isMobile ? "lasso" : "selection",
+ initialized: true,
+ };
+ }
+
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
@@ -2366,12 +2398,18 @@ class App extends React.Component {
// with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool:
- scene.appState.activeTool.type === "image"
- ? { ...scene.appState.activeTool, type: "selection" }
+ activeTool.type === "image" ||
+ activeTool.type === "lasso" ||
+ activeTool.type === "selection"
+ ? {
+ ...activeTool,
+ type: scene.appState.preferredSelectionTool.type,
+ }
: scene.appState.activeTool,
isLoading: false,
toast: this.state.toast,
};
+
if (this.props.scrollConstraints) {
scene.appState = {
...scene.appState,
@@ -2414,25 +2452,32 @@ class App extends React.Component {
private isMobileBreakpoint = (width: number, height: number) => {
return (
- width < MQ_MAX_WIDTH_PORTRAIT ||
+ width <= MQ_MAX_MOBILE ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
);
};
+ private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => {
+ const minSide = Math.min(editorWidth, editorHeight);
+ const maxSide = Math.max(editorWidth, editorHeight);
+
+ return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
+ };
+
private refreshViewportBreakpoints = () => {
const container = this.excalidrawContainerRef.current;
if (!container) {
return;
}
- const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
- document.body;
+ const { width: editorWidth, height: editorHeight } =
+ container.getBoundingClientRect();
const prevViewportState = this.device.viewport;
const nextViewportState = updateObject(prevViewportState, {
- isLandscape: viewportWidth > viewportHeight,
- isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
+ isLandscape: editorWidth > editorHeight,
+ isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
});
if (prevViewportState !== nextViewportState) {
@@ -2463,6 +2508,30 @@ class App extends React.Component {
canFitSidebar: editorWidth > sidebarBreakpoint,
});
+ const stylesPanelMode =
+ // NOTE: we could also remove the isMobileOrTablet check here and
+ // always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP)
+ // but not too narrow (> MQ_MAX_WIDTH_MOBILE)
+ this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
+ ? "compact"
+ : this.isMobileBreakpoint(editorWidth, editorHeight)
+ ? "mobile"
+ : "full";
+
+ // also check if we need to update the app state
+ this.setState((prevState) => ({
+ stylesPanelMode,
+ // reset to box selection mode if the UI changes to full
+ // where you'd not be able to change the mode yourself currently
+ preferredSelectionTool:
+ stylesPanelMode === "full"
+ ? {
+ type: "selection",
+ initialized: true,
+ }
+ : prevState.preferredSelectionTool,
+ }));
+
if (prevEditorState !== nextEditorState) {
this.device = { ...this.device, editor: nextEditorState };
return true;
@@ -3010,6 +3079,7 @@ class App extends React.Component {
private static resetTapTwice() {
didTapTwice = false;
+ firstTapPosition = null;
}
private onTouchStart = (event: TouchEvent) => {
@@ -3020,6 +3090,13 @@ class App extends React.Component {
if (!didTapTwice) {
didTapTwice = true;
+
+ if (event.touches.length === 1) {
+ firstTapPosition = {
+ x: event.touches[0].clientX,
+ y: event.touches[0].clientY,
+ };
+ }
clearTimeout(tappedTwiceTimer);
tappedTwiceTimer = window.setTimeout(
App.resetTapTwice,
@@ -3027,15 +3104,29 @@ class App extends React.Component {
);
return;
}
- // insert text only if we tapped twice with a single finger
+
+ // insert text only if we tapped twice with a single finger at approximately the same position
// event.touches.length === 1 will also prevent inserting text when user's zooming
- if (didTapTwice && event.touches.length === 1) {
+ if (didTapTwice && event.touches.length === 1 && firstTapPosition) {
const touch = event.touches[0];
- // @ts-ignore
- this.handleCanvasDoubleClick({
- clientX: touch.clientX,
- clientY: touch.clientY,
- });
+ const distance = pointDistance(
+ pointFrom(touch.clientX, touch.clientY),
+ pointFrom(firstTapPosition.x, firstTapPosition.y),
+ );
+
+ // only create text if the second tap is within the threshold of the first tap
+ // this prevents accidental text creation during dragging/selection
+ if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) {
+ // end lasso trail and deselect elements just in case
+ this.lassoTrail.endPath();
+ this.deselectElements();
+
+ // @ts-ignore
+ this.handleCanvasDoubleClick({
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ });
+ }
didTapTwice = false;
clearTimeout(tappedTwiceTimer);
}
@@ -3063,7 +3154,166 @@ class App extends React.Component {
}
};
- // TODO: this is so spaghetti, we should refactor it and cover it with tests
+ // TODO: Cover with tests
+ private async insertClipboardContent(
+ data: ClipboardData,
+ dataTransferFiles: ParsedDataTransferFile[],
+ isPlainPaste: boolean,
+ ) {
+ const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+ {
+ clientX: this.lastViewportPosition.x,
+ clientY: this.lastViewportPosition.y,
+ },
+ this.state,
+ );
+
+ // ------------------- Error -------------------
+ if (data.errorMessage) {
+ this.setState({ errorMessage: data.errorMessage });
+ return;
+ }
+
+ // ------------------- Mixed content with no files -------------------
+ if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) {
+ await this.addElementsFromMixedContentPaste(data.mixedContent, {
+ isPlainPaste,
+ sceneX,
+ sceneY,
+ });
+ return;
+ }
+
+ // ------------------- Spreadsheet -------------------
+ if (data.spreadsheet && !isPlainPaste) {
+ this.setState({
+ pasteDialog: {
+ data: data.spreadsheet,
+ shown: true,
+ },
+ });
+ return;
+ }
+
+ // ------------------- Images or SVG code -------------------
+ const imageFiles = dataTransferFiles.map((data) => data.file);
+
+ if (imageFiles.length === 0 && data.text && !isPlainPaste) {
+ const trimmedText = data.text.trim();
+ if (trimmedText.startsWith("