Compare commits

..

33 Commits

Author SHA1 Message Date
dwelle
06dae6edf2 add stats menu to preferences 2025-09-03 13:10:30 +02:00
dwelle
ecbaeb1701 add Preferences default menu item 2025-08-31 23:30:26 +02:00
dwelle
40c5c743b1 Merge branch 'master' into barnabasmolnar/mainmenu-radix
# Conflicts:
#	package.json
#	packages/excalidraw/.size-limit.json
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSubItem.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx
#	packages/excalidraw/components/main-menu/MainMenu.tsx
#	packages/excalidraw/tests/excalidraw.test.tsx
#	packages/excalidraw/tests/library.test.tsx
#	src/components/Actions.tsx
#	src/components/dropdownMenu/DropdownMenu.scss
#	src/components/dropdownMenu/DropdownMenuItem.tsx
#	yarn.lock
2025-08-31 20:18:40 +02:00
barnabasmolnar
5082142b36 Potentially improve main menu positioning. 2023-08-07 14:55:50 +02:00
barnabasmolnar
74cb027fd7 Some slight styling tweaks per design spec. 2023-07-28 13:22:43 +02:00
Aakansha Doshi
bc09ac757f increase the limit for bundle 2023-07-27 13:59:19 +05:30
Aakansha Doshi
66e347f7d2 Merge remote-tracking branch 'origin/master' into barnabasmolnar/mainmenu-radix 2023-07-27 12:17:28 +05:30
barnabasmolnar
d5974e66b2 button => Button, cleanup 2023-07-26 13:01:41 +02:00
barnabasmolnar
2a1b22a504 update test snapshot 2023-07-25 15:52:30 +02:00
barnabasmolnar
b3d241ba7f button => Button in dropdown 2023-07-25 15:48:37 +02:00
barnabasmolnar
8ff1ac8097 Tweak dropdown alignment. 2023-07-25 13:53:00 +02:00
Aakansha Doshi
d967123383 Merge remote-tracking branch 'origin/master' into barnabasmolnar/mainmenu-radix 2023-07-25 09:07:22 +05:30
barnabasmolnar
05cd1a79cc Fix test snapshot. 2023-07-24 16:45:55 +02:00
barnabasmolnar
bd08bdf4c7 Fix some issues caused by too aggressive refactor 2023-07-24 16:35:56 +02:00
barnabasmolnar
011b268dde Merge master + post merge fixes. 2023-07-24 14:16:14 +02:00
barnabasmolnar
b6a7f05761 Attempt to fix tests. 2023-07-24 14:04:37 +02:00
Aakansha Doshi
8787c7d8cf increase size limit for excalidraw.production.min.js 2023-07-24 17:06:39 +05:30
barnabasmolnar
6d21d7cab1 Some post merge fixes. 2023-07-21 16:40:42 +02:00
barnabasmolnar
c9df3e143b Merge branch 'master' into barnabasmolnar/mainmenu-radix 2023-07-21 15:24:54 +02:00
barnabasmolnar
5b11660cc0 Removed MainMenu.Sub docs. 2023-07-21 14:56:12 +02:00
barnabasmolnar
bf0b2965e6 Naming, removing unused code. 2023-07-21 14:30:18 +02:00
barnabasmolnar
8f8b6e7144 Add backdrop under mainmenu on mobile. 2023-04-26 14:28:22 +02:00
barnabasmolnar
b63d17045e Revert "Make main menu full width on mobile again."
This reverts commit 70d48d547207d0cd611ea2683731fe7676a1a0fc.
2023-04-26 12:52:35 +02:00
barnabasmolnar
70d48d5472 Make main menu full width on mobile again. 2023-04-21 15:58:04 +02:00
barnabasmolnar
097000a2b7 Update docs with submenu. 2023-04-18 23:07:21 +02:00
barnabasmolnar
461661afc6 Styling. 2023-04-18 17:11:36 +02:00
barnabasmolnar
c88f3c84eb Bunch of radix related fixes. 2023-04-18 16:39:23 +02:00
barnabasmolnar
7d791b86f8 Attempt to fix submenu not disappearing properly. 2023-04-18 14:57:25 +02:00
barnabasmolnar
e615056302 Quick fix for mobile. 2023-04-18 01:24:31 +02:00
barnabasmolnar
14ad745d00 Initial attempt @ submenu implementation. 2023-04-18 01:04:02 +02:00
barnabasmolnar
9c3ff73a73 Styling fixes, naming. 2023-04-17 17:31:50 +02:00
barnabasmolnar
79cf71cccb Small refactor. Fixes library dropdown. 2023-04-17 17:12:42 +02:00
barnabasmolnar
e094b8b539 Initial attempt @ using radixdropdown for mainmenu 2023-04-17 16:59:05 +02:00
81 changed files with 5527 additions and 5029 deletions

View File

@ -615,6 +615,52 @@ export default function ExampleApp({
const renderMenu = () => { const renderMenu = () => {
return ( return (
<MainMenu> <MainMenu>
<MainMenu.Sub>
<MainMenu.Sub.Trigger
title="Custom trigger"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5"
/>
</svg>
}
>
Submenu trigger
</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
/>
</svg>
}
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export /> <MainMenu.DefaultItems.Export />
<MainMenu.Separator /> <MainMenu.Separator />
@ -622,10 +668,57 @@ export default function ExampleApp({
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onSelect={() => window.alert("You clicked on collab button")} onSelect={() => window.alert("You clicked on collab button")}
/> />
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Group title="Excalidraw links"> <MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
</MainMenu.Group> </MainMenu.Group>
<MainMenu.Separator /> {/* <MainMenu.Separator /> */}
<MainMenu.Sub>
<MainMenu.Sub.Trigger className="custom-classname">
Another submenu trigger
</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content className="custom-classname-for-content">
<MainMenu.Sub.Item
title="Sub item"
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger me</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger me inside</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => {
alert("wow, nested submenus!");
}}
>
Item wow
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Sub.Item
onSelect={() => {
alert("wow, nested submenus! very cool");
}}
>
Another one
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<button <button
style={{ height: "2rem" }} style={{ height: "2rem" }}

View File

@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
// should be aligned with MAX_ALLOWED_FILE_BYTES export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;

View File

@ -39,6 +39,7 @@ export const AppMainMenu: React.FC<{
<MainMenu.DefaultItems.SearchMenu /> <MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.DefaultItems.Preferences />
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.ItemLink <MainMenu.ItemLink
icon={ExcalLogo} icon={ExcalLogo}

View File

@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
}, },
"isTouchScreen": false, "isTouchScreen": false,
"viewport": { "viewport": {
"isLandscape": true, "isLandscape": false,
"isMobile": true, "isMobile": true,
}, },
} }

View File

@ -129,7 +129,6 @@ export const CLASSES = {
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@ -260,17 +259,13 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const STRING_MIME_TYPES = { export const MIME_TYPES = {
text: "text/plain", text: "text/plain",
html: "text/html", html: "text/html",
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data // image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml", "excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png", "excalidraw.png": "image/png",
@ -347,17 +342,10 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints // breakpoints
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// md screen
// mobile: up to 699px export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@ -21,8 +21,6 @@ import {
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isDarwin, isDarwin,
isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@ -1280,59 +1278,3 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue; return commonValue;
}; };
export const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};

View File

@ -1126,9 +1126,7 @@ export interface BoundingBox {
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {

View File

@ -97,7 +97,6 @@ export * from "./image";
export * from "./linearElementEditor"; export * from "./linearElementEditor";
export * from "./mutateElement"; export * from "./mutateElement";
export * from "./newElement"; export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";

View File

@ -1,112 +0,0 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};

View File

@ -1,14 +1,7 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { import { isRightAngleRads } from "@excalidraw/math";
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
@ -21,7 +14,6 @@ import {
getFontString, getFontString,
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@ -40,7 +32,7 @@ import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { getUncroppedImageElement } from "./cropElement"; import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { import {
@ -1047,66 +1039,6 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
} }
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot // If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure const inputPoints = element.simulatePressure
? element.points ? element.points
@ -1125,7 +1057,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
}; };
return getStroke(inputPoints as number[][], options) as [number, number][]; return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
} }
function med(A: number[], B: number[]) { function med(A: number[], B: number[]) {

View File

@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps, data }) => { PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx // FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<ColorPicker <ColorPicker
@ -83,7 +83,6 @@ export const actionChangeViewBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
); );
}, },

View File

@ -88,10 +88,6 @@ export const actionToggleLinearEditor = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t( const label = t(
selectedElement.type === "arrow" selectedElement.type === "arrow"
? "labels.lineEditor.editArrow" ? "labels.lineEditor.editArrow"

View File

@ -137,11 +137,6 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
@ -326,11 +321,9 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
{appState.stylesPanelMode === "full" && ( <h3 aria-hidden="true">{t("labels.stroke")}</h3>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
)}
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS} topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
@ -348,7 +341,6 @@ export const actionChangeStrokeColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
</> </>
), ),
@ -406,11 +398,9 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<> <>
{appState.stylesPanelMode === "full" && ( <h3 aria-hidden="true">{t("labels.background")}</h3>
<h3 aria-hidden="true">{t("labels.background")}</h3>
)}
<ColorPicker <ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
@ -428,7 +418,6 @@ export const actionChangeBackgroundColor = register({
elements={elements} elements={elements}
appState={appState} appState={appState}
updateData={updateData} updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/> />
</> </>
), ),
@ -529,11 +518,9 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<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"
@ -588,11 +575,9 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<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"
@ -643,11 +628,9 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<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"
@ -714,7 +697,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value); return changeFontSize(elements, appState, app, () => value, value);
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset> <fieldset>
<legend>{t("labels.fontSize")}</legend> <legend>{t("labels.fontSize")}</legend>
<div className="buttonList"> <div className="buttonList">
@ -773,14 +756,7 @@ export const actionChangeFontSize = register({
? null ? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE, : appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@ -1040,7 +1016,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
@ -1118,28 +1094,20 @@ export const actionChangeFontFamily = register({
return ( return (
<fieldset> <fieldset>
{appState.stylesPanelMode === "full" && ( <legend>{t("labels.fontFamily")}</legend>
<legend>{t("labels.fontFamily")}</legend>
)}
<FontPicker <FontPicker
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode === "compact"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
withCaretPositionPreservation( setBatchedData({
() => { openPopup: null,
setBatchedData({ currentHoveredFontFamily: null,
openPopup: null, currentItemFontFamily: fontFamily,
currentHoveredFontFamily: null, });
currentItemFontFamily: fontFamily,
}); // 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.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@ -1196,28 +1164,25 @@ export const actionChangeFontFamily = register({
} }
setBatchedData({ setBatchedData({
...batchedData,
openPopup: "fontFamily", openPopup: "fontFamily",
}); });
} else { } else {
const fontFamilyData = { // close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current), cachedElements: new Map(cachedElementsRef.current),
resetAll: true, resetAll: true,
} as ChangeFontFamilyData; } as ChangeFontFamilyData;
setBatchedData({ if (isUnmounted.current) {
...fontFamilyData, // in case the component was unmounted by the parent, trigger the update directly
}); updateData({ ...batchedData, ...data });
cachedElementsRef.current.clear(); } else {
setBatchedData(data);
// Refocus text editor when font picker closes if we were editing text
if (
appState.stylesPanelMode === "compact" &&
appState.editingTextElement
) {
restoreCaretPosition(null); // Just refocus without saved position
} }
cachedElementsRef.current.clear();
} }
}} }}
/> />
@ -1260,9 +1225,8 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@ -1311,14 +1275,7 @@ export const actionChangeTextAlign = register({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@ -1360,7 +1317,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app, data }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@ -1410,14 +1367,7 @@ export const actionChangeVerticalAlign = register({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => { onChange={(value) => updateData(value)}
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@ -1666,25 +1616,6 @@ export const actionChangeArrowhead = register({
}, },
}); });
export const actionChangeArrowProperties = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowType")}
{renderAction("changeArrowhead")}
</div>
);
},
});
export const actionChangeArrowType = register({ export const actionChangeArrowType = register({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",

View File

@ -18,7 +18,6 @@ export {
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties"; } from "./actionProperties";
export { export {

View File

@ -54,7 +54,8 @@ export type ShortcutName =
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette" | "commandPalette"
| "searchMenu"; | "searchMenu"
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [], wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

View File

@ -69,7 +69,6 @@ export type ActionName =
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"

View File

@ -123,7 +123,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
stylesPanelMode: "full",
}; };
}; };
@ -248,7 +247,6 @@ 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 },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

@ -1,7 +1,6 @@
import { import {
createPasteEvent, createPasteEvent,
parseClipboard, parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON, serializeAsClipboardJSON,
} from "./clipboard"; } from "./clipboard";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
@ -14,9 +13,7 @@ describe("parseClipboard()", () => {
text = "123"; text = "123";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@ -24,9 +21,7 @@ describe("parseClipboard()", () => {
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@ -34,9 +29,7 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 }); text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({ types: { "text/plain": text } }),
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
@ -46,13 +39,11 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/plain": json,
"text/plain": json, },
}, }),
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
}); });
@ -65,25 +56,21 @@ describe("parseClipboard()", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": json,
"text/html": json, },
}, }),
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<div> ${json}</div>`,
"text/html": `<div> ${json}</div>`, },
}, }),
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -93,13 +80,11 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<img src="https://example.com/image.png" />`,
"text/html": `<img src="https://example.com/image.png" />`, },
}, }),
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -109,13 +94,11 @@ describe("parseClipboard()", () => {
]); ]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`, },
}, }),
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -131,13 +114,11 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => { it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`, },
}, }),
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -160,16 +141,14 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/plain": `a b
"text/plain": `a b 1 2
1 2 4 5
4 5 7 10`,
7 10`, },
}, }),
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@ -178,16 +157,14 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `a b
"text/html": `a b 1 2
1 2 4 5
4 5 7 10`,
7 10`, },
}, }),
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@ -196,21 +173,19 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent( createPasteEvent({
createPasteEvent({ types: {
types: { "text/html": `<html>
"text/html": `<html> <body>
<body> <!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment--> </body>
</body> </html>`,
</html>`, "text/plain": `a b
"text/plain": `a b 1 2
1 2 4 5
4 5 7 10`,
7 10`, },
}, }),
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",

View File

@ -5,7 +5,6 @@ import {
arrayToMap, arrayToMap,
isMemberOf, isMemberOf,
isPromiseLike, isPromiseLike,
EVENT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element";
@ -17,26 +16,15 @@ import {
import { getContainingFrame } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors"; import { ExcalidrawError } from "./errors";
import { import { createFile, isSupportedImageFileType } from "./data/blob";
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
@ -104,7 +92,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided"); console.warn("createPasteEvent: no types or files provided");
} }
const event = new ClipboardEvent(EVENT.PASTE, { const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(), clipboardData: new DataTransfer(),
}); });
@ -113,11 +101,10 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
event.clipboardData?.items.add(value, type); event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`); throw new Error(`Failed to set "${type}" as clipboardData item`);
} }
@ -242,10 +229,14 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLDataItem = ( const maybeParseHTMLPaste = (
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>, event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = dataItem.value; const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@ -341,21 +332,18 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEventTextData = async (
dataList: ParsedDataTranferList, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEventTextData> => {
try { try {
const htmlItem = dataList.findByType(MIME_TYPES.html); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) { if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) { if (mixedContent.value.every((item) => item.type === "text")) {
return { return {
type: "text", type: "text",
value: value:
dataList.getData(MIME_TYPES.text) ?? event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@ -366,155 +354,23 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
return { const text = event.clipboardData?.getData(MIME_TYPES.text);
type: "text",
value: (dataList.getData(MIME_TYPES.text) || "").trim(), return { type: "text", value: (text || "").trim() };
};
} catch { } catch {
return { type: "text", value: "" }; return { type: "text", value: "" };
} }
}; };
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
return {
type: file.type,
kind: "file",
file: await normalizeFile(file),
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/** /**
* Attempts to parse clipboard event. * Attempts to parse clipboard event.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
dataList: ParsedDataTranferList, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEventTextData(
dataList, event,
isPlainPaste, isPlainPaste,
); );
@ -663,14 +519,3 @@ const copyTextViaExecCommand = (text: string | null) => {
return success; return success;
}; };
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};

View File

@ -91,120 +91,3 @@
} }
} }
} }
.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;
--default-button-size: 2rem;
.compact-action-button {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
}
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
}
.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;
}
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
.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);
}

View File

@ -1,6 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useState } from "react"; import { useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { import {
CLASSES, CLASSES,
@ -20,7 +19,6 @@ import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isArrowElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element"; import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
@ -48,20 +46,15 @@ import {
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { getToolbarTools } from "./shapes"; import { getToolbarTools } from "./shapes";
import "./Actions.scss"; import "./Actions.scss";
import { useDevice, useExcalidrawContainer } from "./App"; import { useDevice } from "./App";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import DropdownMenu from "./dropdownMenu/DropdownMenu"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { PropertiesPopover } from "./PropertiesPopover";
import { import {
EmbedIcon, EmbedIcon,
extraToolsIcon, extraToolsIcon,
@ -70,29 +63,11 @@ import {
laserPointerToolIcon, laserPointerToolIcon,
MagicIcon, MagicIcon,
LassoIcon, LassoIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
TextSizeIcon,
adjustmentsIcon,
DotsHorizontalIcon,
} from "./icons"; } from "./icons";
import type { import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
AppClassProperties,
AppProps,
UIAppState,
Zoom,
AppState,
} from "../types";
import type { ActionManager } from "../actions/manager"; import type { ActionManager } from "../actions/manager";
// Common CSS class combinations
const PROPERTIES_CLASSES = clsx([
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
"properties-content",
]);
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
targetElements: ExcalidrawElement[], targetElements: ExcalidrawElement[],
@ -305,437 +280,6 @@ export const SelectedShapeActions = ({
); );
}; };
export const CompactShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showFillIcons =
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showLinkIcon = targetElements.length === 1;
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showAlignActions = alignActionsPredicate(appState, app);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div>
)}
{/* Combined Properties (Fill, Stroke, Opacity) */}
{(showFillIcons ||
hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeWidth(element.type)) ||
hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type)) ||
canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactStrokeStyles"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactStrokeStyles" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.stroke")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactStrokeStyles"
? null
: "compactStrokeStyles",
});
}}
>
{adjustmentsIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactStrokeStyles" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
<div className="selected-shape-actions">
{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")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Combined Arrow Properties */}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactArrowProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactArrowProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.arrowtypes")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactArrowProperties"
? null
: "compactArrowProperties",
});
}}
>
{(() => {
// Show an icon based on the current arrow type
const arrowType = getFormValue(
targetElements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? "elbow"
: element.roundness
? "round"
: "sharp";
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
);
if (arrowType === "elbow") {
return elbowArrowIcon;
}
if (arrowType === "round") {
return roundArrowIcon;
}
return sharpArrowIcon;
})()}
</button>
</Popover.Trigger>
{appState.openPopup === "compactArrowProperties" && (
<PropertiesPopover
container={container}
className="properties-content"
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
{renderAction("changeArrowProperties")}
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Linear Editor */}
{showLineEditorAction && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactTextProperties"}
onOpenChange={(open) => {
if (open) {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
} else {
setAppState({ openPopup: null });
if (appState.editingTextElement) {
restoreCaretPosition();
}
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.textAlign")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (appState.openPopup === "compactTextProperties") {
setAppState({ openPopup: null });
} else {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
}
}}
>
{TextSizeIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactTextProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onClose={() => {
// Refocus text editor when popover closes with caret restoration
if (appState.editingTextElement) {
restoreCaretPosition();
}
}}
>
<div className="selected-shape-actions">
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) &&
renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
</>
)}
{/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
{/* Combined Other Actions */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactOtherProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactOtherProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.actions")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactOtherProperties"
? null
: "compactOtherProperties",
});
}}
>
{DotsHorizontalIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactOtherProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{
maxWidth: "12rem",
// center the popover content
justifyContent: "center",
alignItems: "center",
}}
onClose={() => {}}
>
<div className="selected-shape-actions">
<fieldset>
<legend>{t("labels.layers")}</legend>
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
</fieldset>
{showAlignActions && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{targetElements.length > 2 &&
renderAction("distributeHorizontally")}
{/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{targetElements.length > 2 &&
renderAction("distributeVertically")}
</div>
</div>
</fieldset>
)}
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
</div>
</fieldset>
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
</div>
);
};
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
activeTool, activeTool,
appState, appState,
@ -853,6 +397,7 @@ export const ShapesSwitcher = ({
onClickOutside={() => setIsExtraToolsMenuOpen(false)} onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)} onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown" className="App-toolbar__extra-tools-dropdown"
align="end"
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })} onSelect={() => app.setActiveTool({ type: "frame" })}
@ -906,10 +451,10 @@ export const ShapesSwitcher = ({
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
</> </>
)} )}

View File

@ -41,6 +41,9 @@ import {
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES, MAX_ALLOWED_FILE_BYTES,
MIME_TYPES, MIME_TYPES,
MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT,
MQ_RIGHT_SIDEBAR_MIN_WIDTH, MQ_RIGHT_SIDEBAR_MIN_WIDTH,
POINTER_BUTTON, POINTER_BUTTON,
ROUNDNESS, ROUNDNESS,
@ -97,12 +100,9 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
isMobile,
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD, DOUBLE_TAP_POSITION_THRESHOLD,
isMobileOrTablet,
MQ_MAX_MOBILE,
MQ_MIN_TABLET,
MQ_MAX_TABLET,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -237,7 +237,6 @@ import {
isSimpleArrow, isSimpleArrow,
StoreDelta, StoreDelta,
type ApplyToOptions, type ApplyToOptions,
positionElementsOnGrid,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@ -324,13 +323,7 @@ import {
isEraserActive, isEraserActive,
isHandToolActive, isHandToolActive,
} from "../appState"; } from "../appState";
import { import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
copyTextToSystemClipboard,
parseClipboard,
parseDataTransferEvent,
type ParsedDataTransferFile,
} from "../clipboard";
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore"; import { restore, restoreElements } from "../data/restore";
@ -352,6 +345,7 @@ import {
generateIdFromFile, generateIdFromFile,
getDataURL, getDataURL,
getDataURL_sync, getDataURL_sync,
getFileFromEvent,
ImageURLToFile, ImageURLToFile,
isImageFileHandle, isImageFileHandle,
isSupportedImageFile, isSupportedImageFile,
@ -438,7 +432,7 @@ import type {
ScrollBars, ScrollBars,
} from "../scene/types"; } from "../scene/types";
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@ -667,7 +661,7 @@ class App extends React.Component<AppProps, AppState> {
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = isMobileOrTablet() this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const) ? ("lasso" as const)
: ("selection" as const); : ("selection" as const);
const { const {
@ -2420,16 +2414,21 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private isMobileBreakpoint = (width: number, height: number) => { private isMobileOrTablet = (): boolean => {
const minSide = Math.min(width, height); const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
return minSide <= MQ_MAX_MOBILE; const hasCoarsePointer =
"matchMedia" in window &&
window?.matchMedia("(pointer: coarse)")?.matches;
const isTouchMobile = hasTouch && hasCoarsePointer;
return isMobile || isTouchMobile;
}; };
private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { private isMobileBreakpoint = (width: number, height: number) => {
const minSide = Math.min(editorWidth, editorHeight); return (
const maxSide = Math.max(editorWidth, editorHeight); width < MQ_MAX_WIDTH_PORTRAIT ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET; );
}; };
private refreshViewportBreakpoints = () => { private refreshViewportBreakpoints = () => {
@ -2438,14 +2437,14 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const { width: editorWidth, height: editorHeight } = const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
container.getBoundingClientRect(); document.body;
const prevViewportState = this.device.viewport; const prevViewportState = this.device.viewport;
const nextViewportState = updateObject(prevViewportState, { const nextViewportState = updateObject(prevViewportState, {
isLandscape: editorWidth > editorHeight, isLandscape: viewportWidth > viewportHeight,
isMobile: this.isMobileBreakpoint(editorWidth, editorHeight), isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
}); });
if (prevViewportState !== nextViewportState) { if (prevViewportState !== nextViewportState) {
@ -2476,17 +2475,6 @@ class App extends React.Component<AppProps, AppState> {
canFitSidebar: editorWidth > sidebarBreakpoint, canFitSidebar: editorWidth > sidebarBreakpoint,
}); });
// also check if we need to update the app state
this.setState({
stylesPanelMode:
// NOTE: we could also remove the isMobileOrTablet check here and
// always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP)
// but not too narrow (> MQ_MAX_WIDTH_MOBILE)
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
? "compact"
: "full",
});
if (prevEditorState !== nextEditorState) { if (prevEditorState !== nextEditorState) {
this.device = { ...this.device, editor: nextEditorState }; this.device = { ...this.device, editor: nextEditorState };
return true; return true;
@ -3078,166 +3066,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
// TODO: Cover with tests // TODO: this is so spaghetti, we should refactor it and cover it 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("<svg") && trimmedText.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
// initialization
imageFiles.push(SVGStringToFile(trimmedText));
}
}
if (imageFiles.length > 0) {
if (this.isToolSupported("image")) {
await this.insertImages(imageFiles, sceneX, sceneY);
} else {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
}
return;
}
// ------------------- Elements -------------------
if (data.elements) {
const elements = (
data.programmaticAPI
? convertToExcalidrawElements(
data.elements as ExcalidrawElementSkeleton[],
)
: data.elements
) as readonly ExcalidrawElement[];
// TODO: remove formatting from elements if isPlainPaste
this.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
position: isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste,
});
return;
}
// ------------------- Only textual stuff remaining -------------------
if (!data.text) {
return;
}
// ------------------- Successful Mermaid -------------------
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true,
});
this.addElementsFromPasteOrLibrary({
elements,
files,
position: isMobileOrTablet() ? "center" : "cursor",
});
return;
} catch (err: any) {
console.warn(
`parsing pasted text as mermaid definition failed: ${err.message}`,
);
}
}
// ------------------- Pure embeddable URLs -------------------
const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean);
const embbeddableUrls = nonEmptyLines
.map((str) => maybeParseEmbedSrc(str))
.filter(
(string) =>
embeddableURLValidator(string, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
getEmbedLink(string)?.type === "video"),
);
if (
!isPlainPaste &&
embbeddableUrls.length > 0 &&
embbeddableUrls.length === nonEmptyLines.length
) {
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
for (const url of embbeddableUrls) {
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
embeddables[embeddables.length - 1];
const embeddable = this.insertEmbeddableElement({
sceneX: prevEmbeddable
? prevEmbeddable.x + prevEmbeddable.width + 20
: sceneX,
sceneY,
link: normalizeLink(url),
});
if (embeddable) {
embeddables.push(embeddable);
}
}
if (embeddables.length) {
this.store.scheduleCapture();
this.setState({
selectedElementIds: Object.fromEntries(
embeddables.map((embeddable) => [embeddable.id, true]),
),
});
}
return;
}
// ------------------- Text -------------------
this.addTextFromPaste(data.text, isPlainPaste);
}
public pasteFromClipboard = withBatchedUpdates( public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent) => { async (event: ClipboardEvent) => {
const isPlainPaste = !!IS_PLAIN_PASTE; const isPlainPaste = !!IS_PLAIN_PASTE;
@ -3262,14 +3091,47 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{
clientX: this.lastViewportPosition.x,
clientY: this.lastViewportPosition.y,
},
this.state,
);
// must be called in the same frame (thus before any awaits) as the paste // must be called in the same frame (thus before any awaits) as the paste
// event else some browsers (FF...) will clear the clipboardData // event else some browsers (FF...) will clear the clipboardData
// (something something security) // (something something security)
const dataTransferList = await parseDataTransferEvent(event); let file = event?.clipboardData?.files[0];
const data = await parseClipboard(event, isPlainPaste);
if (!file && !isPlainPaste) {
if (data.mixedContent) {
return this.addElementsFromMixedContentPaste(data.mixedContent, {
isPlainPaste,
sceneX,
sceneY,
});
} else if (data.text) {
const string = data.text.trim();
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
// ignore SVG validation/normalization which will be done during image
// initialization
file = SVGStringToFile(string);
}
}
}
const filesList = dataTransferList.getFiles(); // prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) {
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
}
const data = await parseClipboard(dataTransferList, isPlainPaste); this.createImageElement({ sceneX, sceneY, imageFile: file });
return;
}
if (this.props.onPaste) { if (this.props.onPaste) {
try { try {
@ -3281,8 +3143,105 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
await this.insertClipboardContent(data, filesList, isPlainPaste); if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
} else if (data.spreadsheet && !isPlainPaste) {
this.setState({
pasteDialog: {
data: data.spreadsheet,
shown: true,
},
});
} else if (data.elements) {
const elements = (
data.programmaticAPI
? convertToExcalidrawElements(
data.elements as ExcalidrawElementSkeleton[],
)
: data.elements
) as readonly ExcalidrawElement[];
// TODO remove formatting from elements if isPlainPaste
this.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste,
});
} else if (data.text) {
if (data.text && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true,
});
this.addElementsFromPasteOrLibrary({
elements,
files,
position: this.isMobileOrTablet() ? "center" : "cursor",
});
return;
} catch (err: any) {
console.warn(
`parsing pasted text as mermaid definition failed: ${err.message}`,
);
}
}
const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/)
.map((s) => s.trim())
.filter(Boolean);
const embbeddableUrls = nonEmptyLines
.map((str) => maybeParseEmbedSrc(str))
.filter((string) => {
return (
embeddableURLValidator(string, this.props.validateEmbeddable) &&
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
getEmbedLink(string)?.type === "video")
);
});
if (
!IS_PLAIN_PASTE &&
embbeddableUrls.length > 0 &&
// if there were non-embeddable text (lines) mixed in with embeddable
// urls, ignore and paste as text
embbeddableUrls.length === nonEmptyLines.length
) {
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
for (const url of embbeddableUrls) {
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
embeddables[embeddables.length - 1];
const embeddable = this.insertEmbeddableElement({
sceneX: prevEmbeddable
? prevEmbeddable.x + prevEmbeddable.width + 20
: sceneX,
sceneY,
link: normalizeLink(url),
});
if (embeddable) {
embeddables.push(embeddable);
}
}
if (embeddables.length) {
this.store.scheduleCapture();
this.setState({
selectedElementIds: Object.fromEntries(
embeddables.map((embeddable) => [embeddable.id, true]),
),
});
}
return;
}
this.addTextFromPaste(data.text, isPlainPaste);
}
this.setActiveTool({ type: this.defaultSelectionTool }, true); this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault(); event?.preventDefault();
}, },
@ -3472,11 +3431,45 @@ class App extends React.Component<AppProps, AppState> {
} }
}), }),
); );
let y = sceneY;
let firstImageYOffsetDone = false;
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
for (const response of responses) {
if (response.file) {
const initializedImageElement = await this.createImageElement({
sceneX,
sceneY: y,
imageFile: response.file,
});
if (initializedImageElement) {
// vertically center first image in the batch
if (!firstImageYOffsetDone) {
firstImageYOffsetDone = true;
y -= initializedImageElement.height / 2;
}
// hack to reset the `y` coord because we vertically center during
// insertImageElement
this.scene.mutateElement(
initializedImageElement,
{ y },
{ informMutation: false, isDragging: false },
);
y = initializedImageElement.y + initializedImageElement.height + 25;
nextSelectedIds[initializedImageElement.id] = true;
}
}
}
this.setState({
selectedElementIds: makeNextSelectedElementIds(
nextSelectedIds,
this.state,
),
});
const imageFiles = responses
.filter((response): response is { file: File } => !!response.file)
.map((response) => response.file);
await this.insertImages(imageFiles, sceneX, sceneY);
const error = responses.find((response) => !!response.errorMessage); const error = responses.find((response) => !!response.errorMessage);
if (error && error.errorMessage) { if (error && error.errorMessage) {
this.setState({ errorMessage: error.errorMessage }); this.setState({ errorMessage: error.errorMessage });
@ -4813,7 +4806,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
} }
if (nextActiveTool.type === "image") { if (nextActiveTool.type === "image") {
this.onImageToolbarButtonClick(); this.onImageAction();
} }
this.setState((prevState) => { this.setState((prevState) => {
@ -6674,6 +6667,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.element && pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element); this.isASelectedElement(pointerDownState.hit.element);
const isMobileOrTablet = this.isMobileOrTablet();
if ( if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType && !pointerDownState.resize.handleType &&
@ -6687,12 +6682,12 @@ class App extends React.Component<AppProps, AppState> {
// block dragging after lasso selection on PCs until the next pointer down // block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately) // (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState.drag.blockDragging = !isMobileOrTablet(); pointerDownState.drag.blockDragging = !isMobileOrTablet;
} }
// only for mobile or tablet, if we hit an element, select it immediately like normal selection // only for mobile or tablet, if we hit an element, select it immediately like normal selection
if ( if (
isMobileOrTablet() && isMobileOrTablet &&
pointerDownState.hit.element && pointerDownState.hit.element &&
!hitSelectedElement !hitSelectedElement
) { ) {
@ -7248,16 +7243,6 @@ class App extends React.Component<AppProps, AppState> {
selectedElements.length === 1 && selectedElements.length === 1 &&
!this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement?.isEditing &&
!isElbowArrow(selectedElements[0]) && !isElbowArrow(selectedElements[0]) &&
!(
isLineElement(selectedElements[0]) &&
LinearElementEditor.getPointIndexUnderCursor(
selectedElements[0],
elementsMap,
this.state.zoom,
pointerDownState.origin.x,
pointerDownState.origin.y,
) !== -1
) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1 this.state.selectedLinearElement.hoverPointIndex !== -1
@ -7857,14 +7842,16 @@ class App extends React.Component<AppProps, AppState> {
return element; return element;
}; };
private newImagePlaceholder = ({ private createImageElement = async ({
sceneX, sceneX,
sceneY, sceneY,
addToFrameUnderCursor = true, addToFrameUnderCursor = true,
imageFile,
}: { }: {
sceneX: number; sceneX: number;
sceneY: number; sceneY: number;
addToFrameUnderCursor?: boolean; addToFrameUnderCursor?: boolean;
imageFile: File;
}) => { }) => {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
sceneX, sceneX,
@ -7883,7 +7870,7 @@ class App extends React.Component<AppProps, AppState> {
const placeholderSize = 100 / this.state.zoom.value; const placeholderSize = 100 / this.state.zoom.value;
return newImageElement({ const placeholderImageElement = newImageElement({
type: "image", type: "image",
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
@ -7900,6 +7887,13 @@ class App extends React.Component<AppProps, AppState> {
width: placeholderSize, width: placeholderSize,
height: placeholderSize, height: placeholderSize,
}); });
const initializedImageElement = await this.insertImageElement(
placeholderImageElement,
imageFile,
);
return initializedImageElement;
}; };
private handleLinearElementOnPointerDown = ( private handleLinearElementOnPointerDown = (
@ -8503,7 +8497,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
this.state.activeTool.type === "lasso" && this.state.activeTool.type === "lasso" &&
this.lassoTrail.hasCurrentTrail && this.lassoTrail.hasCurrentTrail &&
!(isMobileOrTablet() && pointerDownState.hit.element) && !(this.isMobileOrTablet() && pointerDownState.hit.element) &&
!this.state.activeTool.fromSelection !this.state.activeTool.fromSelection
) { ) {
return; return;
@ -10221,7 +10215,64 @@ class App extends React.Component<AppProps, AppState> {
); );
}; };
private onImageToolbarButtonClick = async () => { /**
* inserts image into elements array and rerenders
*/
private insertImageElement = async (
placeholderImageElement: ExcalidrawImageElement,
imageFile: File,
) => {
// we should be handling all cases upstream, but in case we forget to handle
// a future case, let's throw here
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
}
this.scene.insertElement(placeholderImageElement);
try {
const initializedImageElement = await this.initializeImage(
placeholderImageElement,
imageFile,
);
const nextElements = this.scene
.getElementsIncludingDeleted()
.map((element) => {
if (element.id === initializedImageElement.id) {
return initializedImageElement;
}
return element;
});
this.updateScene({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
elements: nextElements,
appState: {
selectedElementIds: makeNextSelectedElementIds(
{ [initializedImageElement.id]: true },
this.state,
),
},
});
return initializedImageElement;
} catch (error: any) {
this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.mutateElement(placeholderImageElement, {
isDeleted: true,
});
this.actionManager.executeAction(actionFinalize);
this.setState({
errorMessage: error.message || t("errors.imageInsertError"),
});
return null;
}
};
private onImageAction = async () => {
try { try {
const clientX = this.state.width / 2 + this.state.offsetLeft; const clientX = this.state.width / 2 + this.state.offsetLeft;
const clientY = this.state.height / 2 + this.state.offsetTop; const clientY = this.state.height / 2 + this.state.offsetTop;
@ -10231,15 +10282,24 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
const imageFiles = await fileOpen({ const imageFile = await fileOpen({
description: "Image", description: "Image",
extensions: Object.keys( extensions: Object.keys(
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[], ) as (keyof typeof IMAGE_MIME_TYPES)[],
multiple: true,
}); });
this.insertImages(imageFiles, x, y); await this.createImageElement({
sceneX: x,
sceneY: y,
addToFrameUnderCursor: false,
imageFile,
});
// avoid being batched (just in case)
this.setState({}, () => {
this.actionManager.executeAction(actionFinalize);
});
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
console.error(error); console.error(error);
@ -10436,117 +10496,63 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private insertImages = async (
imageFiles: File[],
sceneX: number,
sceneY: number,
) => {
const gridPadding = 50 / this.state.zoom.value;
// Create, position, and insert placeholders
const placeholders = positionElementsOnGrid(
imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })),
sceneX,
sceneY,
gridPadding,
);
placeholders.forEach((el) => this.scene.insertElement(el));
// Create, position, insert and select initialized (replacing placeholders)
const initialized = await Promise.all(
placeholders.map(async (placeholder, i) => {
try {
return await this.initializeImage(placeholder, imageFiles[i]);
} catch (error: any) {
this.setState({
errorMessage: error.message || t("errors.imageInsertError"),
});
return newElementWith(placeholder, { isDeleted: true });
}
}),
);
const initializedMap = arrayToMap(initialized);
const positioned = positionElementsOnGrid(
initialized.filter((el) => !el.isDeleted),
sceneX,
sceneY,
gridPadding,
);
const positionedMap = arrayToMap(positioned);
const nextElements = this.scene
.getElementsIncludingDeleted()
.map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el);
this.updateScene({
appState: {
selectedElementIds: makeNextSelectedElementIds(
Object.fromEntries(positioned.map((el) => [el.id, true])),
this.state,
),
},
elements: nextElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
this.setState({}, () => {
// actionFinalize after all state values have been updated
this.actionManager.executeAction(actionFinalize);
});
};
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => { private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
// must be retrieved first, in the same frame
const { file, fileHandle } = await getFileFromEvent(event);
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
); );
const dataTransferList = await parseDataTransferEvent(event);
// must be retrieved first, in the same frame try {
const fileItems = dataTransferList.getFiles(); // if image tool not supported, don't show an error here and let it fall
// through so we still support importing scene data from images. If no
// scene data encoded, we'll show an error then
if (isSupportedImageFile(file) && this.isToolSupported("image")) {
// first attempt to decode scene from the image if it's embedded
// ---------------------------------------------------------------------
if (fileItems.length === 1) { if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
const { file, fileHandle } = fileItems[0]; try {
const scene = await loadFromBlob(
if ( file,
file && this.state,
(file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg) this.scene.getElementsIncludingDeleted(),
) { fileHandle,
try { );
const scene = await loadFromBlob( this.syncActionResult({
file, ...scene,
this.state, appState: {
this.scene.getElementsIncludingDeleted(), ...(scene.appState || this.state),
fileHandle, isLoading: false,
); },
this.syncActionResult({ replaceFiles: true,
...scene, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
appState: { });
...(scene.appState || this.state), return;
isLoading: false, } catch (error: any) {
}, // Don't throw for image scene daa
replaceFiles: true, if (error.name !== "EncodingError") {
captureUpdate: CaptureUpdateAction.IMMEDIATELY, throw new Error(t("alerts.couldNotLoadInvalidFile"));
}); }
return;
} catch (error: any) {
if (error.name !== "EncodingError") {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
// if EncodingError, fall through to insert as regular image
} }
// if no scene is embedded or we fail for whatever reason, fall back
// to importing as regular image
// ---------------------------------------------------------------------
this.createImageElement({ sceneX, sceneY, imageFile: file });
return;
} }
} catch (error: any) {
return this.setState({
isLoading: false,
errorMessage: error.message,
});
} }
const imageFiles = fileItems const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
.map((data) => data.file)
.filter((file) => isSupportedImageFile(file));
if (imageFiles.length > 0 && this.isToolSupported("image")) {
return this.insertImages(imageFiles, sceneX, sceneY);
}
const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib);
if (libraryJSON && typeof libraryJSON === "string") { if (libraryJSON && typeof libraryJSON === "string") {
try { try {
const libraryItems = parseLibraryJSON(libraryJSON); const libraryItems = parseLibraryJSON(libraryJSON);
@ -10561,18 +10567,13 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (fileItems.length > 0) { if (file) {
const { file, fileHandle } = fileItems[0]; // Attempt to parse an excalidraw/excalidrawlib file
if (file) { await this.loadFileToCanvas(file, fileHandle);
// Attempt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle);
}
} }
const textItem = dataTransferList.findByType(MIME_TYPES.text); if (event.dataTransfer?.types?.includes("text/plain")) {
const text = event.dataTransfer?.getData("text");
if (textItem) {
const text = textItem.value;
if ( if (
text && text &&
embeddableURLValidator(text, this.props.validateEmbeddable) && embeddableURLValidator(text, this.props.validateEmbeddable) &&

View File

@ -11,7 +11,7 @@ interface ButtonProps
HTMLButtonElement HTMLButtonElement
> { > {
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
onSelect: () => any; onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => any;
/** whether button is in active state */ /** whether button is in active state */
selected?: boolean; selected?: boolean;
children: React.ReactNode; children: React.ReactNode;
@ -34,7 +34,7 @@ export const Button = ({
return ( return (
<button <button
onClick={composeEventHandlers(rest.onClick, (event) => { onClick={composeEventHandlers(rest.onClick, (event) => {
onSelect(); onSelect(event);
})} })}
type={type} type={type}
className={clsx("excalidraw-button", className, { selected })} className={clsx("excalidraw-button", className, { selected })}

View File

@ -22,12 +22,6 @@
@include isMobile { @include isMobile {
max-width: 11rem; max-width: 11rem;
} }
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
} }
.color-picker__top-picks { .color-picker__top-picks {
@ -36,6 +30,18 @@
align-items: center; align-items: center;
} }
#canvas-bg-color-picker-container {
.color-picker__top-picks {
gap: 0.5rem;
}
.color-picker-container {
@include isMobile {
max-width: none;
}
}
}
.color-picker__button { .color-picker__button {
--radius: 4px; --radius: 4px;
--size: 1.375rem; --size: 1.375rem;
@ -86,16 +92,6 @@
} }
} }
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;

View File

@ -1,6 +1,6 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useEffect } from "react"; import { useRef } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
@ -18,12 +18,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 } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
@ -72,7 +67,6 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null; palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple; topPicks?: ColorTuple;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
compactMode?: boolean;
} }
const ColorPickerPopupContent = ({ const ColorPickerPopupContent = ({
@ -83,8 +77,6 @@ const ColorPickerPopupContent = ({
elements, elements,
palette = COLOR_PALETTE, palette = COLOR_PALETTE,
updateData, updateData,
getOpenPopup,
appState,
}: Pick< }: Pick<
ColorPickerProps, ColorPickerProps,
| "type" | "type"
@ -94,10 +86,7 @@ const ColorPickerPopupContent = ({
| "elements" | "elements"
| "palette" | "palette"
| "updateData" | "updateData"
| "appState" >) => {
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
@ -128,8 +117,6 @@ const ColorPickerPopupContent = ({
<PropertiesPopover <PropertiesPopover
container={container} container={container}
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); focusPickerContent();
@ -144,23 +131,8 @@ const ColorPickerPopupContent = ({
} }
}} }}
onClose={() => { onClose={() => {
// only clear if we're still the active popup (avoid racing with switch) updateData({ openPopup: null });
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
setActiveColorPickerSection(null); setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}} }}
> >
{palette ? ( {palette ? (
@ -169,17 +141,7 @@ const ColorPickerPopupContent = ({
palette={palette} palette={palette}
color={color} color={color}
onChange={(changedColor) => { onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor); onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}} }}
onEyeDropperToggle={(force) => { onEyeDropperToggle={(force) => {
setEyeDropperState((state) => { setEyeDropperState((state) => {
@ -206,7 +168,6 @@ const ColorPickerPopupContent = ({
if (eyeDropperState) { if (eyeDropperState) {
setEyeDropperState(null); setEyeDropperState(null);
} else { } else {
// close explicitly on Escape
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
}} }}
@ -227,32 +188,11 @@ const ColorPickerTrigger = ({
label, label,
color, color,
type, type,
compactMode = false,
mode = "background",
onToggle,
editingTextElement,
}: { }: {
color: string | null; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
compactMode?: boolean;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => { }) => {
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
@ -268,37 +208,8 @@ const ColorPickerTrigger = ({
? t("labels.showStroke") ? t("labels.showStroke")
: t("labels.showBackground") : t("labels.showBackground")
} }
data-openpopup={type}
onClick={handleClick}
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline">{!color && slashIcon}</div>
{compactMode && color && (
<div className="color-picker__button-background">
{mode === "background" ? (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{backgroundIcon}
</span>
) : (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
)}
</div>
)}
</Popover.Trigger> </Popover.Trigger>
); );
}; };
@ -313,59 +224,25 @@ export const ColorPicker = ({
topPicks, topPicks,
updateData, updateData,
appState, appState,
compactMode = false,
}: ColorPickerProps) => { }: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
return ( return (
<div> <div>
<div <div role="dialog" aria-modal="true" className="color-picker-container">
role="dialog" <TopPicks
aria-modal="true" activeColor={color}
className={clsx("color-picker-container", { onChange={onChange}
"color-picker-container--no-top-picks": compactMode, type={type}
})} topPicks={topPicks}
> />
{!compactMode && ( <ButtonSeparator />
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { updateData({ openPopup: open ? type : null });
updateData({ openPopup: type });
}
}} }}
> >
{/* serves as an active color indicator as well */} {/* serves as an active color indicator as well */}
<ColorPickerTrigger <ColorPickerTrigger color={color} label={label} type={type} />
color={color}
label={label}
type={type}
compactMode={compactMode}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */} {/* popup content */}
{appState.openPopup === type && ( {appState.openPopup === type && (
<ColorPickerPopupContent <ColorPickerPopupContent
@ -376,8 +253,6 @@ export const ColorPicker = ({
elements={elements} elements={elements}
palette={palette} palette={palette}
updateData={updateData} updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/> />
)} )}
</Popover.Root> </Popover.Root>

View File

@ -11,10 +11,5 @@
2rem + 4 * var(--default-button-size) 2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons ); // 4 gaps + 4 buttons
} }
&--compact {
display: block;
grid-template-columns: none;
}
} }
} }

View File

@ -1,5 +1,4 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
@ -59,7 +58,6 @@ interface FontPickerProps {
onHover: (fontFamily: FontFamilyValues) => void; onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void; onLeave: () => void;
onPopupChange: (open: boolean) => void; onPopupChange: (open: boolean) => void;
compactMode?: boolean;
} }
export const FontPicker = React.memo( export const FontPicker = React.memo(
@ -71,7 +69,6 @@ export const FontPicker = React.memo(
onHover, onHover,
onLeave, onLeave,
onPopupChange, onPopupChange,
compactMode = false,
}: FontPickerProps) => { }: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback( const onSelectCallback = useCallback(
@ -84,29 +81,18 @@ export const FontPicker = React.memo(
); );
return ( return (
<div <div role="dialog" aria-modal="true" className="FontPicker__container">
role="dialog" <div className="buttonList">
aria-modal="true" <RadioSelection<FontFamilyValues | false>
className={clsx("FontPicker__container", { type="button"
"FontPicker__container--compact": compactMode, options={defaultFonts}
})} value={selectedFontFamily}
> onClick={onSelectCallback}
{!compactMode && (
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
/> />
</div>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}

View File

@ -25,10 +25,6 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { import {
FontFamilyCodeIcon, FontFamilyCodeIcon,
FontFamilyHeadingIcon, FontFamilyHeadingIcon,
@ -36,8 +32,15 @@ import {
FreedrawIcon, FreedrawIcon,
} from "../icons"; } from "../icons";
import { Ellipsify } from "../Ellipsify";
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import {
FontPickerListItem,
FontPickerListItemBadgeType,
} from "./FontPickerListItem";
import type { JSX } from "react"; import type { JSX } from "react";
export interface FontDescriptor { export interface FontDescriptor {
@ -46,7 +49,7 @@ export interface FontDescriptor {
text: string; text: string;
deprecated?: true; deprecated?: true;
badge?: { badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>; type: ValueOf<typeof FontPickerListItemBadgeType>;
placeholder: string; placeholder: string;
}; };
} }
@ -90,8 +93,7 @@ export const FontPickerList = React.memo(
onClose, onClose,
}: FontPickerListProps) => { }: FontPickerListProps) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const app = useApp(); const { fonts } = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -113,7 +115,7 @@ export const FontPickerList = React.memo(
Object.assign(fontDescriptor, { Object.assign(fontDescriptor, {
deprecated: metadata.deprecated, deprecated: metadata.deprecated,
badge: { badge: {
type: DropDownMenuItemBadgeType.RED, type: FontPickerListItemBadgeType.RED,
placeholder: t("fontList.badge.old"), placeholder: t("fontList.badge.old"),
}, },
}); });
@ -188,42 +190,6 @@ export const FontPickerList = React.memo(
onLeave, onLeave,
]); ]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>( const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => { (event) => {
const handled = fontPickerKeyHandler({ const handled = fontPickerKeyHandler({
@ -231,7 +197,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect: wrappedOnSelect, onSelect,
onHover, onHover,
onClose, onClose,
}); });
@ -241,7 +207,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], [hoveredFont, filteredFonts, onSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@ -264,7 +230,7 @@ export const FontPickerList = React.memo(
); );
const renderFont = (font: FontDescriptor, index: number) => ( const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem <FontPickerListItem
key={font.value} key={font.value}
icon={font.icon} icon={font.icon}
value={font.value} value={font.value}
@ -276,8 +242,8 @@ export const FontPickerList = React.memo(
selected={font.value === selectedFontFamily} selected={font.value === selectedFontFamily}
// allow to tab between search and selected font // allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1} tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => { onSelect={() => {
wrappedOnSelect(Number(e.currentTarget.value)); onSelect(font.value);
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@ -285,13 +251,13 @@ export const FontPickerList = React.memo(
} }
}} }}
> >
{font.text} <Ellipsify>{font.text}</Ellipsify>
{font.badge && ( {font.badge && (
<DropDownMenuItemBadge type={font.badge.type}> <FontPickerListItem.Badge type={font.badge.type}>
{font.badge.placeholder} {font.badge.placeholder}
</DropDownMenuItemBadge> </FontPickerListItem.Badge>
)} )}
</DropdownMenuItem> </FontPickerListItem>
); );
const groups = []; const groups = [];
@ -319,24 +285,9 @@ export const FontPickerList = React.memo(
className="properties-content" className="properties-content"
container={container} container={container}
style={{ width: "15rem" }} style={{ width: "15rem" }}
onClose={() => { onClose={onClose}
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave} onPointerLeave={onLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch <QuickSearch
ref={inputRef} ref={inputRef}

View File

@ -0,0 +1,151 @@
import React, { useEffect, useRef } from "react";
import { THEME } from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App";
import { useDevice } from "../App";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
shortcut,
children,
}: {
icon?: React.ReactNode;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export const FontPickerListItem = ({
icon,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
value?: string | number | undefined;
order?: number;
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<div className="radix-menu-item">
<Button
{...rest}
ref={ref}
onSelect={onSelect}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</div>
);
};
FontPickerListItem.displayName = "FontPickerListItem";
export const FontPickerListItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const FontPickerListItemBadge = ({
type = FontPickerListItemBadgeType.BLUE,
children,
}: {
type?: ValueOf<typeof FontPickerListItemBadgeType>;
children: React.ReactNode;
}) => {
const { theme } = useExcalidrawAppState();
const style = {
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case FontPickerListItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case FontPickerListItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case FontPickerListItemBadgeType.BLUE:
default:
Object.assign(style, {
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
});
}
return (
<div className="DropDownMenuItemBadge" style={style}>
{children}
</div>
);
};
FontPickerListItemBadge.displayName = "DropdownMenuItemBadge";
FontPickerListItem.Badge = FontPickerListItemBadge;

View File

@ -1,4 +1,5 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import type { FontFamilyValues } from "@excalidraw/element/types"; import type { FontFamilyValues } from "@excalidraw/element/types";
@ -6,38 +7,33 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon"; import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons"; import { TextIcon } from "../icons";
import { useExcalidrawSetAppState } from "../App"; import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
isOpened = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState(); const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger"> {/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon <ButtonIcon
standalone standalone
icon={TextIcon} icon={TextIcon}
title={t("labels.showFonts")} title={t("labels.showFonts")}
className="properties-trigger" className="properties-trigger"
testId={"font-family-show-fonts"} testId={"font-family-show-fonts"}
active={isOpened} active={isTriggerActive}
onClick={() => { // no-op
setAppState((appState) => ({ onClick={() => {}}
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
}}
/> />
</div> </div>
</Popover.Trigger> </Popover.Trigger>

View File

@ -238,7 +238,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]} shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true} isOr={true}
/> />
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} /> <Shortcut
label={t("toolBar.lock")}
shortcuts={[getShortcutFromShortcutName("toolLock")]}
/>
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]} shortcuts={[getShortcutKey("CtrlOrCmd")]}

View File

@ -24,10 +24,6 @@
gap: 0.75rem; gap: 0.75rem;
pointer-events: none !important; pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * { & > * {
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
} }

View File

@ -4,7 +4,6 @@ import React from "react";
import { import {
CLASSES, CLASSES,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE, TOOL_TYPE,
arrayToMap, arrayToMap,
capitalizeString, capitalizeString,
@ -29,11 +28,7 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
SelectedShapeActions,
ShapesSwitcher,
CompactShapeActions,
} from "./Actions";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
@ -162,25 +157,6 @@ const LayerUI = ({
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const spacing =
appState.stylesPanelMode === "compact"
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@ -233,55 +209,31 @@ const LayerUI = ({
</div> </div>
); );
const renderSelectedShapeActions = () => { const renderSelectedShapeActions = () => (
const isCompactMode = appState.stylesPanelMode === "compact"; <Section
heading="selectedShapeActions"
return ( className={clsx("selected-shape-actions zen-mode-transition", {
<Section "transition-left": appState.zenModeEnabled,
heading="selectedShapeActions" })}
className={clsx("selected-shape-actions zen-mode-transition", { >
"transition-left": appState.zenModeEnabled, <Island
})} className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
> >
{isCompactMode ? ( <SelectedShapeActions
<Island appState={appState}
className={clsx("compact-shape-actions-island")} elementsMap={app.scene.getNonDeletedElementsMap()}
padding={0} renderAction={actionManager.renderAction}
style={{ app={app}
// we want to make sure this doesn't overflow so subtracting the />
// approximate height of hamburgerMenu + footer </Island>
maxHeight: `${appState.height - 166}px`, </Section>
}} );
>
<CompactShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
)}
</Section>
);
};
const renderFixedSideContainer = () => { const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions( const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -298,19 +250,9 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()} {renderCanvasActions()}
<div {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
appState.stylesPanelMode === "compact",
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && ( appState.openDialog?.name !== "elementLinkSelector" && (
@ -320,19 +262,17 @@ const LayerUI = ({
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={spacing.toolbarColGap} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
gap={spacing.toolbarRowGap} gap={1}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<Island <Island
padding={spacing.islandPadding} padding={1}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
"App-toolbar--compact":
appState.stylesPanelMode === "compact",
})} })}
> >
<HintViewer <HintViewer
@ -342,7 +282,7 @@ const LayerUI = ({
app={app} app={app}
/> />
{heading} {heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}> <Stack.Row gap={1}>
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
@ -376,7 +316,7 @@ const LayerUI = ({
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
marginLeft: spacing.collabMarginLeft, marginLeft: 8,
alignSelf: "center", alignSelf: "center",
height: "fit-content", height: "fit-content",
}} }}
@ -404,8 +344,6 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact":
appState.stylesPanelMode === "compact",
}, },
)} )}
> >
@ -480,9 +418,7 @@ const LayerUI = ({
}} }}
tab={DEFAULT_SIDEBAR.defaultTab} tab={DEFAULT_SIDEBAR.defaultTab}
> >
{appState.stylesPanelMode === "full" && {t("toolBar.library")}
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
t("toolBar.library")}
</DefaultSidebar.Trigger> </DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog /> <DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />} {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}

View File

@ -194,6 +194,7 @@ export const LibraryDropdownMenuButton: React.FC<{
<DropdownMenu open={isLibraryMenuOpen}> <DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)} onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
aria-label="Library menu"
> >
{DotsIcon} {DotsIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@ -201,6 +202,7 @@ export const LibraryDropdownMenuButton: React.FC<{
onClickOutside={() => setIsLibraryMenuOpen(false)} onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)} onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu" className="library-menu"
align="end"
> >
{!itemsSelected && ( {!itemsSelected && (
<DropdownMenu.Item <DropdownMenu.Item

View File

@ -17,7 +17,6 @@ interface PropertiesPopoverProps {
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>; onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"]; onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"]; onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
preventAutoFocusOnTouch?: boolean;
} }
export const PropertiesPopover = React.forwardRef< export const PropertiesPopover = React.forwardRef<
@ -35,7 +34,6 @@ export const PropertiesPopover = React.forwardRef<
onFocusOutside, onFocusOutside,
onPointerLeave, onPointerLeave,
onPointerDownOutside, onPointerDownOutside,
preventAutoFocusOnTouch = false,
}, },
ref, ref,
) => { ) => {
@ -66,12 +64,6 @@ export const PropertiesPopover = React.forwardRef<
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside} onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside} onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => {
// prevent auto-focus on touch devices to avoid keyboard popup
if (preventAutoFocusOnTouch && device.isTouchScreen) {
e.preventDefault();
}
}}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
e.stopPropagation(); e.stopPropagation();
// prevents focusing the trigger // prevents focusing the trigger

View File

@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } }); setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}} }}
icon={icon ?? brainIcon} icon={icon ?? brainIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{children ?? t("labels.textToDiagram")} {children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
</TTDDialogTriggerTunnel.In> </TTDDialogTriggerTunnel.In>
); );

View File

@ -10,16 +10,6 @@
} }
} }
&--compact {
.ToolIcon__keybinding {
display: none;
}
.App-toolbar__divider {
margin: 0;
}
}
&__divider { &__divider {
width: 1px; width: 1px;
height: 1.5rem; height: 1.5rem;

View File

@ -1,20 +1,45 @@
@import "../../css/variables.module.scss"; @import "../../css/variables.module";
.excalidraw { .excalidraw {
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
z-index: 2 !important;
}
.dropdown-menu { .dropdown-menu {
position: absolute; max-width: 16rem;
top: 100%; margin-top: 0.25rem;
margin-top: 0.5rem;
&__submenu-trigger {
&[aria-expanded="true"] {
.dropdown-menu-item {
background-color: var(--button-hover-bg);
}
}
}
&__submenu-trigger-icon {
margin-left: auto;
opacity: 0.5;
}
.radix-menu-item {
&:focus-visible {
outline: none;
}
}
.dropdown-submenu {
margin-left: -0.75rem;
min-width: 16rem;
max-width: 20rem;
}
&--mobile { &--mobile {
left: 0;
width: 100%;
row-gap: 0.75rem;
.dropdown-menu-container { .dropdown-menu-container {
grid-template-columns: minmax(0, 1fr);
padding: 8px 8px; padding: 8px 8px;
box-sizing: border-box; box-sizing: border-box;
// background-color: var(--island-bg-color); background-color: var(--island-bg-color);
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;
@ -30,13 +55,14 @@
.dropdown-menu-container { .dropdown-menu-container {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
max-height: calc(100vh - 150px); max-height: var(--radix-popper-available-height);
overflow-y: auto; overflow-y: auto;
--gap: 2; --gap: 2;
} }
.dropdown-menu-item-base { .dropdown-menu-item-base {
display: flex; display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem; column-gap: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-on-surface); color: var(--color-on-surface);
@ -44,6 +70,7 @@
box-sizing: border-box; box-sizing: border-box;
font-weight: 400; font-weight: 400;
font-family: inherit; font-family: inherit;
justify-content: flex-start;
} }
&.manual-hover { &.manual-hover {

View File

@ -1,5 +1,7 @@
import React from "react"; import React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import DropdownMenuContent from "./DropdownMenuContent"; import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuGroup from "./DropdownMenuGroup"; import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuItem from "./DropdownMenuItem"; import DropdownMenuItem from "./DropdownMenuItem";
@ -23,11 +25,12 @@ const DropdownMenu = ({
}) => { }) => {
const MenuTriggerComp = getMenuTriggerComponent(children); const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children); const MenuContentComp = getMenuContentComponent(children);
return ( return (
<> <DropdownMenuPrimitive.Root open={open} modal={false}>
{MenuTriggerComp} {MenuTriggerComp}
{open && MenuContentComp} {MenuContentComp}
</> </DropdownMenuPrimitive.Root>
); );
}; };

View File

@ -3,6 +3,8 @@ import React, { useEffect, useRef } from "react";
import { EVENT, KEYS } from "@excalidraw/common"; import { EVENT, KEYS } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable"; import { useStable } from "../../hooks/useStable";
import { useDevice } from "../App"; import { useDevice } from "../App";
@ -17,6 +19,9 @@ const MenuContent = ({
className = "", className = "",
onSelect, onSelect,
style, style,
sideOffset = 4,
align = "start",
collisionPadding,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
@ -26,6 +31,11 @@ const MenuContent = ({
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
sideOffset?: number;
align?: "start" | "center" | "end";
collisionPadding?:
| number
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -62,11 +72,15 @@ const MenuContent = ({
return ( return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}> <DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div <DropdownMenuPrimitive.Content
ref={menuRef} ref={menuRef}
className={classNames} className={classNames}
style={style} style={style}
data-testid="dropdown-menu" data-testid="dropdown-menu"
side="bottom"
sideOffset={sideOffset}
align={align}
collisionPadding={collisionPadding}
> >
{/* 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 */}
@ -81,7 +95,7 @@ const MenuContent = ({
{children} {children}
</Island> </Island>
)} )}
</div> </DropdownMenuPrimitive.Content>
</DropdownMenuContentPropsContext.Provider> </DropdownMenuContentPropsContext.Provider>
); );
}; };

View File

@ -1,12 +1,17 @@
import React, { useEffect, useRef } from "react"; import React, { useRef } from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App"; import { useExcalidrawAppState } from "../App";
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
import { import {
getDropdownMenuItemClassName, getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick, useHandleDropdownMenuItemClick,
@ -17,55 +22,45 @@ import type { JSX } from "react";
const DropdownMenuItem = ({ const DropdownMenuItem = ({
icon, icon,
value, value,
badge,
order, order,
children, children,
shortcut, shortcut,
className, className,
hovered,
selected, selected,
textStyle,
onSelect, onSelect,
onClick, onClick,
...rest ...rest
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined; value?: string | number | undefined;
order?: number; order?: number;
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
hovered?: boolean;
selected?: boolean; selected?: boolean;
textStyle?: React.CSSProperties;
className?: string; className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null); const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return ( return (
<button <DropdownMenuPrimitive.Item className="radix-menu-item">
{...rest} <Button
ref={ref} {...rest}
value={value} ref={ref}
onClick={handleClick} onSelect={handleClick}
className={getDropdownMenuItemClassName(className, selected, hovered)} className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
> >
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}> <MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
{children} {children}
</MenuItemContent> </MenuItemContent>
</button> </Button>
</DropdownMenuPrimitive.Item>
); );
}; };
DropdownMenuItem.displayName = "DropdownMenuItem"; DropdownMenuItem.displayName = "DropdownMenuItem";

View File

@ -2,25 +2,24 @@ import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify"; import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({ const MenuItemContent = ({
textStyle,
icon, icon,
badge,
shortcut, shortcut,
children, children,
}: { }: {
icon?: JSX.Element; icon?: React.ReactNode;
shortcut?: string; shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode; children: React.ReactNode;
badge?: React.ReactNode;
}) => { }) => {
const device = useDevice(); const device = useDevice();
return ( return (
<> <>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>} {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text"> <div className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify> <Ellipsify>{children}</Ellipsify>
{badge}
</div> </div>
{shortcut && !device.editor.isMobile && ( {shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div> <div className="dropdown-menu-item__shortcut">{shortcut}</div>

View File

@ -0,0 +1,27 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
getSubMenuContentComponent,
getSubMenuTriggerComponent,
} from "./dropdownMenuUtils";
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
import DropdownMenuSubContent from "./DropdownMenuSubContent";
import DropdownMenuSubItem from "./DropdownMenuSubItem";
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
const MenuTriggerComp = getSubMenuTriggerComponent(children);
const MenuContentComp = getSubMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Sub>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Sub>
);
};
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
DropdownMenuSub.Content = DropdownMenuSubContent;
DropdownMenuSub.Item = DropdownMenuSubItem;
export default DropdownMenuSub;
DropdownMenuSub.displayName = "DropdownMenuSub";

View File

@ -0,0 +1,44 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import clsx from "clsx";
import { useDevice } from "../App";
import Stack from "../Stack";
import { Island } from "../Island";
const DropdownMenuSubContent = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const device = useDevice();
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile,
}).trim();
return (
<DropdownMenuPrimitive.SubContent
className={classNames}
sideOffset={8}
alignOffset={-4}
>
{device.editor.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={1}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.SubContent>
);
};
export default DropdownMenuSubContent;
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";

View File

@ -0,0 +1,45 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Button } from "../Button";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuSubItem = ({
icon,
onSelect,
children,
shortcut,
className,
...rest
}: {
icon?: React.ReactNode;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
onSelect={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</DropdownMenuPrimitive.Item>
);
};
export default DropdownMenuSubItem;
DropdownMenuSubItem.displayName = "DropdownMenuSubItem";

View File

@ -0,0 +1,39 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import React from "react";
import { ChevronRight } from "../icons";
import MenuItemContent from "./DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "./common";
import type { JSX } from "react";
const DropdownMenuSubTrigger = ({
children,
icon,
className,
...rest
}: {
children: React.ReactNode;
icon?: JSX.Element;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<DropdownMenuPrimitive.SubTrigger className="radix-menu-item dropdown-menu__submenu-trigger">
<div
{...rest}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon}>{children}</MenuItemContent>
<div className="dropdown-menu__submenu-trigger-icon">
{ChevronRight}
</div>
</div>
</DropdownMenuPrimitive.SubTrigger>
);
};
export default DropdownMenuSubTrigger;
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";

View File

@ -1,5 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
@ -23,7 +25,8 @@ const MenuTrigger = ({
}, },
).trim(); ).trim();
return ( return (
<button <DropdownMenuPrimitive.Trigger
data-dropdown-menu-trigger
data-prevent-outside-click data-prevent-outside-click
className={classNames} className={classNames}
onClick={onToggle} onClick={onToggle}
@ -33,7 +36,7 @@ const MenuTrigger = ({
{...rest} {...rest}
> >
{children} {children}
</button> </DropdownMenuPrimitive.Trigger>
); );
}; };

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => { const getMenuComponent = (component: string) => (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find( const comp = React.Children.toArray(children).find(
(child) => (child) =>
React.isValidElement(child) && React.isValidElement(child) &&
@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
//@ts-ignore //@ts-ignore
child?.type.displayName && child?.type.displayName &&
//@ts-ignore //@ts-ignore
child.type.displayName === "DropdownMenuTrigger", child.type.displayName === component,
); );
if (!comp) { if (!comp) {
return null; return null;
@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
return comp; return comp;
}; };
export const getMenuContentComponent = (children: React.ReactNode) => { export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
const comp = React.Children.toArray(children).find( export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
(child) => export const getSubMenuTriggerComponent = getMenuComponent(
React.isValidElement(child) && "DropdownMenuSubTrigger",
typeof child.type !== "string" && );
//@ts-ignore export const getSubMenuContentComponent = getMenuComponent(
child?.type.displayName && "DropdownMenuSubContent",
//@ts-ignore );
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};

View File

@ -72,6 +72,15 @@ const modifiedTablerIconProps: Opts = {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
//tabler-icons: chevron-right
export const ChevronRight = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</g>,
tablerIconProps,
);
// tabler-icons: present // tabler-icons: present
export const PlusPromoIcon = createIcon( export const PlusPromoIcon = createIcon(
<g strokeWidth="1.5"> <g strokeWidth="1.5">
@ -118,17 +127,6 @@ export const DotsIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
export const DotsHorizontalIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</g>,
tablerIconProps,
);
// tabler-icons: pinned // tabler-icons: pinned
export const PinIcon = createIcon( export const PinIcon = createIcon(
<svg strokeWidth="1.5"> <svg strokeWidth="1.5">
@ -407,19 +405,6 @@ export const TextIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const TextSizeIcon = createIcon(
<g stroke="currentColor" strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 7v-2h13v2" />
<path d="M10 5v14" />
<path d="M12 19h-4" />
<path d="M15 13v-1h6v1" />
<path d="M18 12v7" />
<path d="M17 19h2" />
</g>,
tablerIconProps,
);
// modified tabler-icons: photo // modified tabler-icons: photo
export const ImageIcon = createIcon( export const ImageIcon = createIcon(
<g strokeWidth="1.25"> <g strokeWidth="1.25">
@ -2294,17 +2279,8 @@ export const elementLinkIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const resizeIcon = createIcon( export const settingsIcon = createIcon(
<g strokeWidth={1.5}> <g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
<path d="M4 12h7a1 1 0 0 1 1 1v7" />
</g>,
tablerIconProps,
);
export const adjustmentsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /> <path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" /> <path d="M4 6l8 0" />
@ -2319,22 +2295,4 @@ export const adjustmentsIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const backgroundIcon = createIcon( export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
<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(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="6" width="12" height="12" fill="none" />
</g>,
tablerIconProps,
);

View File

@ -1,7 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { Button } from "../Button"; import { Button } from "../Button";
import { share } from "../icons"; import { share } from "../icons";
@ -19,8 +17,7 @@ const LiveCollaborationTrigger = ({
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => { } & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useUIAppState(); const appState = useUIAppState();
const showIconOnly = const showIconOnly = appState.width < 830;
isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
return ( return (
<Button <Button

View File

@ -9,8 +9,11 @@ import {
actionLoadScene, actionLoadScene,
actionSaveToActiveFile, actionSaveToActiveFile,
actionShortcuts, actionShortcuts,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleSearchMenu, actionToggleSearchMenu,
actionToggleTheme, actionToggleTheme,
actionToggleZenMode,
} from "../../actions"; } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
@ -23,13 +26,23 @@ import {
useExcalidrawActionManager, useExcalidrawActionManager,
useExcalidrawElements, useExcalidrawElements,
useAppProps, useAppProps,
useApp,
} from "../App"; } from "../App";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans"; import Trans from "../Trans";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
checkIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@ -313,7 +326,10 @@ export const ChangeCanvasBackground = () => {
> >
{t("labels.canvasBackground")} {t("labels.canvasBackground")}
</div> </div>
<div style={{ padding: "0 0.625rem" }}> <div
style={{ padding: "0 0.625rem" }}
id="canvas-bg-color-picker-container"
>
{actionManager.renderAction("changeViewBackgroundColor")} {actionManager.renderAction("changeViewBackgroundColor")}
</div> </div>
</div> </div>
@ -393,3 +409,73 @@ export const LiveCollaborationTrigger = ({
}; };
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger"; LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
export const Preferences = ({ children }: { children?: React.ReactNode }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
const app = useApp();
return (
<DropdownMenuSub>
<DropdownMenuSub.Trigger icon={settingsIcon}>
{t("labels.preferences")}
</DropdownMenuSub.Trigger>
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
<DropdownMenuSub.Item
icon={appState.activeTool.locked ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("toolLock")}
onSelect={(event) => {
app.toggleLock();
event.preventDefault();
}}
>
{t("labels.preferences_toolLock")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.objectsSnapModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleObjectsSnapMode);
event.preventDefault();
}}
>
{t("buttons.objectsSnapMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.gridModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("gridMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleGridMode);
event.preventDefault();
}}
>
{t("labels.toggleGrid")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.zenModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("zenMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleZenMode);
event.preventDefault();
}}
>
{t("buttons.zenMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.viewModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("viewMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleViewMode);
event.preventDefault();
}}
>
{t("labels.viewMode")}
</DropdownMenuSub.Item>
{children}
</DropdownMenuSub.Content>
</DropdownMenuSub>
);
};
Preferences.displayName = "Preferences";

View File

@ -2,8 +2,12 @@ import React from "react";
import { composeEventHandlers } from "@excalidraw/common"; import { composeEventHandlers } from "@excalidraw/common";
import * as Portal from "@radix-ui/react-portal";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useDevice, useExcalidrawSetAppState } from "../App"; import { useDevice, useExcalidrawSetAppState } from "../App";
import { UserList } from "../UserList"; import { UserList } from "../UserList";
@ -36,6 +40,17 @@ const MainMenu = Object.assign(
return ( return (
<MainMenuTunnel.In> <MainMenuTunnel.In>
{appState.openMenu === "canvas" && device.editor.isMobile && (
<Portal.Root
style={{
backgroundColor: "rgba(18, 18, 18, 0.2)",
position: "fixed",
inset: "0px",
// zIndex: "var(--zIndex-layerUI)",
}}
onClick={() => setAppState({ openMenu: null })}
/>
)}
<DropdownMenu open={appState.openMenu === "canvas"}> <DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => { onToggle={() => {
@ -44,15 +59,27 @@ const MainMenu = Object.assign(
}); });
}} }}
data-testid="main-menu-trigger" data-testid="main-menu-trigger"
aria-label="Main menu"
className="main-menu-trigger" className="main-menu-trigger"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
sideOffset={device.editor.isMobile ? 20 : undefined}
className="main-menu-content"
onClickOutside={onClickOutside} onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => { onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
})} })}
collisionPadding={
// accounting for
// - editor footer on desktop
// - toolbar on mobile
// we probably don't want the menu to overlay these elements
!device.editor.isMobile
? { bottom: 90, top: 10 }
: { top: 90, bottom: 10 }
}
> >
{children} {children}
{device.editor.isMobile && appState.collaborators.size > 0 && ( {device.editor.isMobile && appState.collaborators.size > 0 && (
@ -78,6 +105,7 @@ const MainMenu = Object.assign(
ItemCustom: DropdownMenu.ItemCustom, ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group, Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator, Separator: DropdownMenu.Separator,
Sub: DropdownMenuSub,
DefaultItems, DefaultItems,
}, },
); );

View File

@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
.App-menu_top { .App-menu_top {
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: 1fr 2fr 1fr;
grid-gap: 1rem; grid-gap: 2rem;
align-items: flex-start; align-items: flex-start;
cursor: default; cursor: default;
pointer-events: none !important; pointer-events: none !important;
@ -336,14 +336,6 @@ body.excalidraw-cursor-resize * {
justify-self: flex-start; justify-self: flex-start;
} }
.selected-shape-actions-container {
width: fit-content;
&--compact {
min-width: 48px;
}
}
.App-menu_top > *:last-child { .App-menu_top > *:last-child {
justify-self: flex-end; justify-self: flex-end;
} }

View File

@ -144,6 +144,7 @@
--color-logo-icon: var(--color-primary); --color-logo-icon: var(--color-primary);
--color-logo-text: #190064; --color-logo-text: #190064;
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem; --border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;

View File

@ -96,8 +96,6 @@ export const getMimeType = (blob: Blob | string): string => {
return MIME_TYPES.jpg; return MIME_TYPES.jpg;
} else if (/\.svg$/.test(name)) { } else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg; return MIME_TYPES.svg;
} else if (/\.excalidrawlib$/.test(name)) {
return MIME_TYPES.excalidrawlib;
} }
return ""; return "";
}; };
@ -391,18 +389,23 @@ export const ImageURLToFile = async (
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}; };
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
return { file: file ? await normalizeFile(file) : null, fileHandle };
};
export const getFileHandle = async ( export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem, event: React.DragEvent<HTMLDivElement>,
): Promise<FileSystemHandle | null> => { ): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) { if (nativeFileSystemSupported) {
try { try {
const dataTransferItem = const item = event.dataTransfer.items[0];
event instanceof DataTransferItem
? event
: (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemHandle | null = const handle: FileSystemHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null; (await (item as any).getAsFileSystemHandle()) || null;
return handle; return handle;
} catch (error: any) { } catch (error: any) {

View File

@ -1,26 +1,11 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
distanceToElement,
doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds,
getFreedrawOutlineAsSegments,
getFreedrawOutlinePoints,
intersectElementWithLineSegment, intersectElementWithLineSegment,
isArrowElement,
isFreeDrawElement,
isLineElement,
isPointInElement, isPointInElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import { lineSegment, pointFrom } from "@excalidraw/math";
lineSegment,
lineSegmentsDistance,
pointFrom,
polygon,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
@ -28,8 +13,6 @@ import { shouldTestInside } from "@excalidraw/element";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/element";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
@ -113,7 +96,6 @@ export class EraserTrail extends AnimatedTrail {
pathSegment, pathSegment,
element, element,
candidateElementsMap, candidateElementsMap,
this.app.state.zoom.value,
); );
if (intersects) { if (intersects) {
@ -149,7 +131,6 @@ export class EraserTrail extends AnimatedTrail {
pathSegment, pathSegment,
element, element,
candidateElementsMap, candidateElementsMap,
this.app.state.zoom.value,
); );
if (intersects) { if (intersects) {
@ -199,33 +180,8 @@ const eraserTest = (
pathSegment: LineSegment<GlobalPoint>, pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: number,
): boolean => { ): boolean => {
const lastPoint = pathSegment[1]; const lastPoint = pathSegment[1];
// PERF: Do a quick bounds intersection test first because it's cheap
const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2;
const segmentBounds = [
Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold,
Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold,
Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold,
Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold,
] as Bounds;
const origElementBounds = getElementBounds(element, elementsMap);
const elementBounds: Bounds = [
origElementBounds[0] - threshold,
origElementBounds[1] - threshold,
origElementBounds[2] + threshold,
origElementBounds[3] + threshold,
];
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
return false;
}
// There are shapes where the inner area should trigger erasing
// even though the eraser path segment doesn't intersect with or
// get close to the shape's stroke
if ( if (
shouldTestInside(element) && shouldTestInside(element) &&
isPointInElement(lastPoint, element, elementsMap) isPointInElement(lastPoint, element, elementsMap)
@ -233,50 +189,6 @@ const eraserTest = (
return true; return true;
} }
// Freedraw elements are tested for erasure by measuring the distance
// of the eraser path and the freedraw shape outline lines to a tolerance
// which offers a good visual precision at various zoom levels
if (isFreeDrawElement(element)) {
const outlinePoints = getFreedrawOutlinePoints(element);
const strokeSegments = getFreedrawOutlineAsSegments(
element,
outlinePoints,
elementsMap,
);
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
for (const seg of strokeSegments) {
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
return true;
}
}
const poly = polygon(
...(outlinePoints.map(([x, y]) =>
pointFrom<GlobalPoint>(element.x + x, element.y + y),
) as GlobalPoint[]),
);
// PERF: Check only one point of the eraser segment. If the eraser segment
// start is inside the closed freedraw shape, the other point is either also
// inside or the eraser segment will intersect the shape outline anyway
if (polygonIncludesPointNonZero(pathSegment[0], poly)) {
return true;
}
return false;
} else if (
isArrowElement(element) ||
(isLineElement(element) && !element.polygon)
) {
const tolerance = Math.max(
element.strokeWidth,
(element.strokeWidth * 2) / zoom,
);
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
return ( return (

View File

@ -1,112 +0,0 @@
import { useState, useCallback } from "react";
// Utility type for caret position
export type CaretPosition = {
start: number;
end: number;
};
// Utility function to get text editor element
const getTextEditor = (): HTMLTextAreaElement | null => {
return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
};
// Utility functions for caret position management
export const saveCaretPosition = (): CaretPosition | null => {
const textEditor = getTextEditor();
if (textEditor) {
return {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
return null;
};
export const restoreCaretPosition = (position: CaretPosition | null): void => {
setTimeout(() => {
const textEditor = getTextEditor();
if (textEditor) {
textEditor.focus();
if (position) {
textEditor.selectionStart = position.start;
textEditor.selectionEnd = position.end;
}
}
}, 0);
};
export const withCaretPositionPreservation = (
callback: () => void,
isCompactMode: boolean,
isEditingText: boolean,
onPreventClose?: () => void,
): void => {
// Prevent popover from closing in compact mode
if (isCompactMode && onPreventClose) {
onPreventClose();
}
// Save caret position if editing text
const savedPosition =
isCompactMode && isEditingText ? saveCaretPosition() : null;
// Execute the callback
callback();
// Restore caret position if needed
if (isCompactMode && isEditingText) {
restoreCaretPosition(savedPosition);
}
};
// Hook for managing text editor caret position with state
export const useTextEditorFocus = () => {
const [savedCaretPosition, setSavedCaretPosition] =
useState<CaretPosition | null>(null);
const saveCaretPositionToState = useCallback(() => {
const position = saveCaretPosition();
setSavedCaretPosition(position);
}, []);
const restoreCaretPositionFromState = useCallback(() => {
setTimeout(() => {
const textEditor = getTextEditor();
if (textEditor) {
textEditor.focus();
if (savedCaretPosition) {
textEditor.selectionStart = savedCaretPosition.start;
textEditor.selectionEnd = savedCaretPosition.end;
setSavedCaretPosition(null);
}
}
}, 0);
}, [savedCaretPosition]);
const clearSavedPosition = useCallback(() => {
setSavedCaretPosition(null);
}, []);
return {
saveCaretPosition: saveCaretPositionToState,
restoreCaretPosition: restoreCaretPositionFromState,
clearSavedPosition,
hasSavedPosition: !!savedCaretPosition,
};
};
// Utility function to temporarily disable text editor blur
export const temporarilyDisableTextEditorBlur = (
duration: number = 100,
): void => {
const textEditor = getTextEditor();
if (textEditor) {
const originalOnBlur = textEditor.onblur;
textEditor.onblur = null;
setTimeout(() => {
textEditor.onblur = originalOnBlur;
}, duration);
}
};

View File

@ -171,7 +171,9 @@
"linkToElement": "Link to object", "linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame", "wrapSelectionInFrame": "Wrap selection in frame",
"tab": "Tab", "tab": "Tab",
"shapeSwitch": "Switch shape" "shapeSwitch": "Switch shape",
"preferences": "Preferences",
"preferences_toolLock": "Tool lock"
}, },
"elementLink": { "elementLink": {
"title": "Link to object", "title": "Link to object",

View File

@ -81,11 +81,13 @@
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/mermaid-to-excalidraw": "1.1.3",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-popover": "1.1.6", "@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-tabs": "1.1.3", "@radix-ui/react-tabs": "1.1.3",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
@ -97,8 +99,8 @@
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "2.11.0", "jotai": "2.11.0",
"jotai-scope": "0.7.2", "jotai-scope": "0.7.2",
"lodash.throttle": "4.1.1",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "2.0.3", "pako": "2.0.3",

View File

@ -981,7 +981,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1173,7 +1172,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
@ -1386,7 +1384,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1716,7 +1713,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2046,7 +2042,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
@ -2257,7 +2252,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2499,7 +2493,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2801,7 +2794,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3167,7 +3159,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
@ -3659,7 +3650,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3981,7 +3971,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4306,7 +4295,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5590,7 +5578,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6808,7 +6795,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7738,7 +7724,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8736,7 +8721,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9729,7 +9713,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

View File

@ -688,7 +688,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-haspopup="dialog" aria-haspopup="dialog"
aria-label="Canvas background" aria-label="Canvas background"
class="color-picker__button active-color properties-trigger has-outline" class="color-picker__button active-color properties-trigger has-outline"
data-openpopup="canvasBackground"
data-state="closed" data-state="closed"
style="--swatch-color: #ffffff;" style="--swatch-color: #ffffff;"
title="Show background color picker" title="Show background color picker"

View File

@ -100,7 +100,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -715,7 +714,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1199,7 +1197,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1562,7 +1559,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1928,7 +1924,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2189,7 +2184,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2631,7 +2625,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2933,7 +2926,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3251,7 +3243,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3544,7 +3535,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3829,7 +3819,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4063,7 +4052,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4319,7 +4307,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4589,7 +4576,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4817,7 +4803,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5045,7 +5030,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5291,7 +5275,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5546,7 +5529,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5802,7 +5784,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6130,7 +6111,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6559,7 +6539,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6938,7 +6917,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7238,7 +7216,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7553,7 +7530,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7782,7 +7758,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8133,7 +8108,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8490,7 +8464,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8889,7 +8862,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9177,7 +9149,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9440,7 +9411,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9704,7 +9674,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9936,7 +9905,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10231,7 +10199,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10577,7 +10544,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10815,7 +10781,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11261,7 +11226,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11518,7 +11482,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11754,7 +11717,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11988,7 +11950,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -12330,7 +12291,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"editingGroupId": null, "editingGroupId": null,
"editingTextElement": null, "editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": "Couldn't load invalid file",
"exportBackground": true, "exportBackground": true,
"exportEmbedScene": false, "exportEmbedScene": false,
"exportScale": 1, "exportScale": 1,
@ -12395,7 +12356,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -12574,7 +12534,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -12601,7 +12564,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -12797,7 +12759,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@ -12811,7 +12772,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -12833,7 +12793,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "id2", "fileId": "fileId",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@ -12856,53 +12816,16 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"updated": 1, "updated": 1,
"version": 7, "version": 5,
"width": 318, "width": 318,
"x": -212, "x": -159,
"y": "-167.50000", "y": "-167.50000",
} }
`; `;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] element 1 1`] = ` exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `1`;
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"id": "id1",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"updated": 1,
"version": 7,
"width": 56,
"x": 156,
"y": "-167.50000",
}
`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `2`; exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `7`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `8`;
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`; exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`;
@ -12914,7 +12837,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"deleted": { "deleted": {
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
}, },
"inserted": { "inserted": {
@ -12932,7 +12854,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "id2", "fileId": "fileId",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@ -12953,58 +12875,20 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"version": 7, "version": 5,
"width": 318, "width": 318,
"x": -212, "x": -159,
"y": "-167.50000", "y": "-167.50000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 4,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"version": 7,
"width": 56,
"x": 156,
"y": "-167.50000",
},
"inserted": {
"isDeleted": true,
"version": 6,
}, },
}, },
}, },
"updated": {}, "updated": {},
}, },
"id": "id7", "id": "id4",
}, },
] ]
`; `;
@ -13080,7 +12964,10 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -13094,7 +12981,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@ -13108,7 +12994,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13130,11 +13015,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "id2", "fileId": "fileId",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 335, "height": 77,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -13153,53 +13038,16 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"updated": 1, "updated": 1,
"version": 7, "version": 5,
"width": 318,
"x": -212,
"y": "-167.50000",
}
`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] element 1 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"id": "id1",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"updated": 1,
"version": 7,
"width": 56, "width": 56,
"x": 156, "x": -28,
"y": "-167.50000", "y": "-38.50000",
} }
`; `;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `2`; exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `8`; exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `7`;
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`; exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`;
@ -13211,7 +13059,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"deleted": { "deleted": {
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true,
}, },
}, },
"inserted": { "inserted": {
@ -13229,11 +13076,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"boundElements": null, "boundElements": null,
"crop": null, "crop": null,
"customData": undefined, "customData": undefined,
"fileId": "id2", "fileId": "fileId",
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 335, "height": 77,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -13250,58 +13097,20 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "image", "type": "image",
"version": 7, "version": 5,
"width": 318,
"x": -212,
"y": "-167.50000",
},
"inserted": {
"isDeleted": true,
"version": 6,
},
},
"id1": {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"crop": null,
"customData": undefined,
"fileId": "id3",
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 77,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"scale": [
1,
1,
],
"status": "pending",
"strokeColor": "transparent",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "image",
"version": 7,
"width": 56, "width": 56,
"x": 156, "x": -28,
"y": "-167.50000", "y": "-38.50000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 4,
}, },
}, },
}, },
"updated": {}, "updated": {},
}, },
"id": "id7", "id": "id4",
}, },
] ]
`; `;
@ -13405,7 +13214,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13648,7 +13456,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13884,7 +13691,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14120,7 +13926,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14366,7 +14171,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14697,7 +14501,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14868,7 +14671,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -15149,7 +14951,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -15411,7 +15212,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -15564,7 +15364,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -15844,7 +15643,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -16006,7 +15804,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -16710,7 +16507,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -17344,7 +17140,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -17976,7 +17771,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -18697,7 +18491,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -19447,7 +19240,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -19928,7 +19720,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -20433,7 +20224,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -20893,7 +20683,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

View File

@ -108,7 +108,6 @@ exports[`given element A and group of elements B and given both are selected whe
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -535,7 +534,6 @@ exports[`given element A and group of elements B and given both are selected whe
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -941,7 +939,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1506,7 +1503,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -1717,7 +1713,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2097,7 +2092,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2339,7 +2333,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2520,7 +2513,6 @@ exports[`regression tests > can drag element that covers another element, while
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -2842,7 +2834,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3098,7 +3089,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3338,7 +3328,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3573,7 +3562,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -3831,7 +3819,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4143,7 +4130,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4605,7 +4591,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -4859,7 +4844,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5161,7 +5145,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5340,7 +5323,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5539,7 +5521,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -5935,7 +5916,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -6225,7 +6205,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7085,7 +7064,6 @@ exports[`regression tests > given a group of selected elements with an element t
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7418,7 +7396,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7695,7 +7672,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -7929,7 +7905,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8166,7 +8141,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8345,7 +8319,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8524,7 +8497,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8730,7 +8702,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -8959,7 +8930,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9157,7 +9127,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9381,7 +9350,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9583,7 +9551,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9789,7 +9756,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -9989,7 +9955,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10166,7 +10131,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10363,7 +10327,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -10550,7 +10513,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11074,7 +11036,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11349,7 +11310,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11473,7 +11433,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11676,7 +11635,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -11996,7 +11954,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -12428,7 +12385,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13058,7 +13014,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13184,7 +13139,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -13843,7 +13797,6 @@ exports[`regression tests > switches from group of selected elements to another
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14180,7 +14133,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14411,7 +14363,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14535,7 +14486,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -14924,7 +14874,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
@ -15049,7 +14998,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

View File

@ -35,23 +35,20 @@ describe("appState", () => {
expect(h.state.viewBackgroundColor).toBe("#F00"); expect(h.state.viewBackgroundColor).toBe("#F00");
}); });
await API.drop([ await API.drop(
{ new Blob(
kind: "file", [
file: new Blob( JSON.stringify({
[ type: EXPORT_DATA_TYPES.excalidraw,
JSON.stringify({ appState: {
type: EXPORT_DATA_TYPES.excalidraw, viewBackgroundColor: "#000",
appState: { },
viewBackgroundColor: "#000", elements: [API.createElement({ type: "rectangle", id: "A" })],
}, }),
elements: [API.createElement({ type: "rectangle", id: "A" })], ],
}), { type: MIME_TYPES.json },
], ),
{ type: MIME_TYPES.json }, );
),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

View File

@ -1,13 +1,18 @@
import { queryByText, queryByTestId } from "@testing-library/react"; import { queryByText, queryByTestId } from "@testing-library/react";
import React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import { t } from "../i18n"; import { t } from "../i18n";
import { Excalidraw, Footer, MainMenu } from "../index"; import { Excalidraw, Footer } from "..";
import MainMenu from "../components/main-menu/MainMenu";
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils"; import {
render,
togglePopover,
fireEvent,
GlobalTestState,
} from "./test-utils";
const { h } = window; const { h } = window;
@ -15,7 +20,7 @@ describe("<Excalidraw/>", () => {
afterEach(() => { afterEach(() => {
const menu = document.querySelector(".dropdown-menu"); const menu = document.querySelector(".dropdown-menu");
if (menu) { if (menu) {
toggleMenu(document.querySelector(".excalidraw")!); togglePopover("Main menu");
} }
}); });
@ -136,7 +141,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={undefined} />, <Excalidraw UIOptions={undefined} />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot(); expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
}); });
@ -145,7 +150,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />, <Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "clear-canvas-button")).toBeNull(); expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
}); });
@ -154,7 +159,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />, <Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "json-export-button")).toBeNull(); expect(queryByTestId(container, "json-export-button")).toBeNull();
}); });
@ -163,7 +168,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />, <Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "image-export-button")).toBeNull(); expect(queryByTestId(container, "image-export-button")).toBeNull();
}); });
@ -182,7 +187,7 @@ describe("<Excalidraw/>", () => {
/>, />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "save-as-button")).toBeNull(); expect(queryByTestId(container, "save-as-button")).toBeNull();
}); });
@ -193,7 +198,7 @@ describe("<Excalidraw/>", () => {
/>, />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "save-button")).toBeNull(); expect(queryByTestId(container, "save-button")).toBeNull();
}); });
@ -204,7 +209,7 @@ describe("<Excalidraw/>", () => {
/>, />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "canvas-background-label")).toBeNull(); expect(queryByTestId(container, "canvas-background-label")).toBeNull();
expect(queryByTestId(container, "canvas-background-picker")).toBeNull(); expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
}); });
@ -220,7 +225,7 @@ describe("<Excalidraw/>", () => {
</Excalidraw>, </Excalidraw>,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "canvas-background-label")).toBeNull(); expect(queryByTestId(container, "canvas-background-label")).toBeNull();
expect(queryByTestId(container, "canvas-background-picker")).toBeNull(); expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
}); });
@ -230,7 +235,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />, <Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull(); expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
}); });
@ -251,8 +256,8 @@ describe("<Excalidraw/>", () => {
</Excalidraw>, </Excalidraw>,
); );
//open menu //open menu
toggleMenu(container);
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false` // load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
togglePopover("Main menu");
expect(queryByTestId(container, "load-button")).toBeNull(); expect(queryByTestId(container, "load-button")).toBeNull();
}); });
}); });
@ -263,7 +268,7 @@ describe("<Excalidraw/>", () => {
const { container } = await render(<Excalidraw />); const { container } = await render(<Excalidraw />);
expect(h.state.theme).toBe(THEME.LIGHT); expect(h.state.theme).toBe(THEME.LIGHT);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy(); expect(darkModeToggle).toBeTruthy();
}); });
@ -273,7 +278,7 @@ describe("<Excalidraw/>", () => {
expect(h.state.theme).toBe(THEME.DARK); expect(h.state.theme).toBe(THEME.DARK);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null); expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
}); });
@ -286,7 +291,7 @@ describe("<Excalidraw/>", () => {
); );
expect(h.state.theme).toBe(THEME.DARK); expect(h.state.theme).toBe(THEME.DARK);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy(); expect(darkModeToggle).toBeTruthy();
}); });
@ -300,7 +305,7 @@ describe("<Excalidraw/>", () => {
); );
expect(h.state.theme).toBe(THEME.DARK); expect(h.state.theme).toBe(THEME.DARK);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBe(null); expect(darkModeToggle).toBe(null);
}); });
@ -310,7 +315,7 @@ describe("<Excalidraw/>", () => {
it("should allow editing name", async () => { it("should allow editing name", async () => {
const { container } = await render(<Excalidraw />); const { container } = await render(<Excalidraw />);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
fireEvent.click(queryByTestId(container, "image-export-button")!); fireEvent.click(queryByTestId(container, "image-export-button")!);
const textInput: HTMLInputElement | null = document.querySelector( const textInput: HTMLInputElement | null = document.querySelector(
".ImageExportModal .ImageExportModal__preview__filename .TextInput", ".ImageExportModal .ImageExportModal__preview__filename .TextInput",
@ -323,7 +328,7 @@ describe("<Excalidraw/>", () => {
const name = "test"; const name = "test";
const { container } = await render(<Excalidraw name={name} />); const { container } = await render(<Excalidraw name={name} />);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
await fireEvent.click(queryByTestId(container, "image-export-button")!); await fireEvent.click(queryByTestId(container, "image-export-button")!);
const textInput = document.querySelector( const textInput = document.querySelector(
".ImageExportModal .ImageExportModal__preview__filename .TextInput", ".ImageExportModal .ImageExportModal__preview__filename .TextInput",
@ -375,7 +380,7 @@ describe("<Excalidraw/>", () => {
</Excalidraw>, </Excalidraw>,
); );
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot(); expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
}); });
@ -394,7 +399,7 @@ describe("<Excalidraw/>", () => {
const { container } = await render(<CustomExcalidraw />); const { container } = await render(<CustomExcalidraw />);
//open menu //open menu
toggleMenu(container); togglePopover("Main menu");
expect(h.state.theme).toBe(THEME.LIGHT); expect(h.state.theme).toBe(THEME.LIGHT);

View File

@ -57,7 +57,7 @@ describe("export", () => {
blob: pngBlob, blob: pngBlob,
metadata: serializeAsJSON(testElements, h.state, {}, "local"), metadata: serializeAsJSON(testElements, h.state, {}, "local"),
}); });
await API.drop([{ kind: "file", file: pngBlobEmbedded }]); await API.drop(pngBlobEmbedded);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -94,12 +94,7 @@ describe("export", () => {
}); });
it("import embedded png (legacy v1)", async () => { it("import embedded png (legacy v1)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
{
kind: "file",
file: await API.loadFile("./fixtures/test_embedded_v1.png"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "test" }), expect.objectContaining({ type: "text", text: "test" }),
@ -108,12 +103,7 @@ describe("export", () => {
}); });
it("import embedded png (v2)", async () => { it("import embedded png (v2)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
{
kind: "file",
file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }), expect.objectContaining({ type: "text", text: "😀" }),
@ -122,12 +112,7 @@ describe("export", () => {
}); });
it("import embedded svg (legacy v1)", async () => { it("import embedded svg (legacy v1)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
{
kind: "file",
file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "test" }), expect.objectContaining({ type: "text", text: "test" }),
@ -136,12 +121,7 @@ describe("export", () => {
}); });
it("import embedded svg (v2)", async () => { it("import embedded svg (v2)", async () => {
await API.drop([ await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
{
kind: "file",
file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
},
]);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }), expect.objectContaining({ type: "text", text: "😀" }),

View File

@ -1,9 +0,0 @@
export const DEER_IMAGE_DIMENSIONS = {
width: 318,
height: 335,
};
export const SMILEY_IMAGE_DIMENSIONS = {
width: 56,
height: 77,
};

View File

@ -25,7 +25,6 @@ import { Excalidraw } from "../index";
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason) // Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
import * as blobModule from "../data/blob"; import * as blobModule from "../data/blob";
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { import {
@ -745,6 +744,11 @@ describe("freedraw", () => {
//image //image
//TODO: currently there is no test for pixel colors at flipped positions. //TODO: currently there is no test for pixel colors at flipped positions.
describe("image", () => { describe("image", () => {
const smileyImageDimensions = {
width: 56,
height: 77,
};
beforeEach(() => { beforeEach(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image // it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000; h.state.height = 1000;
@ -752,8 +756,8 @@ describe("image", () => {
beforeAll(() => { beforeAll(() => {
mockHTMLImageElement( mockHTMLImageElement(
SMILEY_IMAGE_DIMENSIONS.width, smileyImageDimensions.width,
SMILEY_IMAGE_DIMENSIONS.height, smileyImageDimensions.height,
); );
}); });

View File

@ -478,43 +478,33 @@ export class API {
}); });
}; };
static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => { static drop = async (blob: Blob) => {
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas); const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const text = await new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.readAsText(blob);
} catch (error: any) {
reject(error);
}
});
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[]; const files = [blob] as File[] & { item: (index: number) => File };
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
files.item = (index: number) => files[index]; files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", { Object.defineProperty(fileDropEvent, "dataTransfer", {
value: { value: {
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
files, files,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
items: items.map((item, idx) => {
if (item.kind === "string") {
return {
kind: "string",
type: item.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
getAsString: (cb: (text: string) => any) => cb(item.value),
};
}
return {
kind: "file",
type: item.type || item.file.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
getAsFile: () => item.file,
};
}),
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
getData: (type: string) => { getData: (type: string) => {
return items.find((item) => item.type === "string" && item.type === type) || ""; if (type === blob.type || type === "text") {
return text;
}
return "";
}, },
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types types: [blob.type],
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", { Object.defineProperty(fileDropEvent, "clientX", {

View File

@ -1,6 +0,0 @@
export const INITIALIZED_IMAGE_PROPS = {
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
};

View File

@ -58,35 +58,3 @@ export const mockHTMLImageElement = (
}, },
); );
}; };
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
export const mockMultipleHTMLImageElements = (
sizes: (readonly [number, number])[],
) => {
const _sizes = [...sizes];
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
const size = _sizes.shift();
if (!size) {
throw new Error("Insufficient sizes");
}
Object.defineProperty(this, "naturalWidth", {
value: size[0],
});
Object.defineProperty(this, "naturalHeight", {
value: size[1],
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};

View File

@ -47,43 +47,42 @@ class DataTransferItem {
} }
} }
class DataTransferItemList extends Array<DataTransferItem> { class DataTransferList {
items: DataTransferItem[] = [];
add(data: string | File, type: string = ""): void { add(data: string | File, type: string = ""): void {
if (typeof data === "string") { if (typeof data === "string") {
this.push(new DataTransferItem("string", type, data)); this.items.push(new DataTransferItem("string", type, data));
} else if (data instanceof File) { } else if (data instanceof File) {
this.push(new DataTransferItem("file", type, data)); this.items.push(new DataTransferItem("file", type, data));
} }
} }
clear(): void { clear(): void {
this.clear(); this.items = [];
} }
} }
class DataTransfer { class DataTransfer {
public items: DataTransferItemList = new DataTransferItemList(); public items: DataTransferList = new DataTransferList();
private _types: Record<string, string> = {};
get files() { get files() {
return this.items return this.items.items
.filter((item) => item.kind === "file") .filter((item) => item.kind === "file")
.map((item) => item.getAsFile()!); .map((item) => item.getAsFile()!);
} }
add(data: string | File, type: string = ""): void { add(data: string | File, type: string = ""): void {
if (typeof data === "string") { this.items.add(data, type);
this.items.add(data, type);
} else {
this.items.add(data);
}
} }
setData(type: string, value: string) { setData(type: string, value: string) {
this.items.add(value, type); this._types[type] = value;
} }
getData(type: string) { getData(type: string) {
return this.items.find((item) => item.type === type)?.data || ""; return this._types[type] || "";
} }
} }

View File

@ -20,7 +20,6 @@ import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed, reseed,
randomId,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@ -59,13 +58,9 @@ import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob"; import * as blobModule from "../data/blob";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants"; import { mockHTMLImageElement } from "./helpers/mocks";
import { import {
GlobalTestState, GlobalTestState,
act, act,
@ -76,7 +71,6 @@ import {
checkpointHistory, checkpointHistory,
unmountComponent, unmountComponent,
} from "./test-utils"; } from "./test-utils";
import { setupImageTest as _setupImageTest } from "./image.test";
import type { AppState } from "../types"; import type { AppState } from "../types";
@ -129,9 +123,7 @@ describe("history", () => {
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile"); const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile"); const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
Promise.resolve(randomId() as FileId),
);
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file)); resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, { Object.assign(document, {
@ -568,24 +560,21 @@ describe("history", () => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]), expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
); );
await API.drop([ await API.drop(
{ new Blob(
kind: "file", [
file: new Blob( JSON.stringify({
[ type: EXPORT_DATA_TYPES.excalidraw,
JSON.stringify({ appState: {
type: EXPORT_DATA_TYPES.excalidraw, ...getDefaultAppState(),
appState: { viewBackgroundColor: "#000",
...getDefaultAppState(), },
viewBackgroundColor: "#000", elements: [API.createElement({ type: "rectangle", id: "B" })],
}, }),
elements: [API.createElement({ type: "rectangle", id: "B" })], ],
}), { type: MIME_TYPES.json },
], ),
{ type: MIME_TYPES.json }, );
),
},
]);
await waitFor(() => expect(API.getUndoStack().length).toBe(1)); await waitFor(() => expect(API.getUndoStack().length).toBe(1));
expect(h.state.viewBackgroundColor).toBe("#000"); expect(h.state.viewBackgroundColor).toBe("#000");
@ -623,17 +612,89 @@ describe("history", () => {
]); ]);
}); });
it("should create new history entry on image drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const deerImageDimensions = {
width: 318,
height: 335,
};
mockHTMLImageElement(
deerImageDimensions.width,
deerImageDimensions.height,
);
await API.drop(await API.loadFile("./fixtures/deer.png"));
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...deerImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...deerImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => { it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg"; const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop([ await API.drop(
{ new Blob([link], {
kind: "string",
value: link,
type: MIME_TYPES.text, type: MIME_TYPES.text,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
@ -669,29 +730,54 @@ describe("history", () => {
]); ]);
}); });
const setupImageTest = () => it("should create new history entry on image paste", async () => {
_setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]); await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const smileyImageDimensions = {
width: 56,
height: 77,
};
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
document.dispatchEvent(
createPasteEvent({
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
}),
);
const assertImageTest = async () => {
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
// need to check that delta actually contains initialized image elements (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed).map(
(val) => val.deleted,
),
).toEqual([
expect.objectContaining({ expect.objectContaining({
...INITIALIZED_IMAGE_PROPS, type: "image",
...DEER_IMAGE_DIMENSIONS, fileId: expect.any(String),
}), x: expect.toBeNonNaNNumber(),
expect.objectContaining({ y: expect.toBeNonNaNNumber(),
...INITIALIZED_IMAGE_PROPS, ...smileyImageDimensions,
...SMILEY_IMAGE_DIMENSIONS,
}), }),
]); ]);
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
).toEqual(
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
);
}); });
Keyboard.undo(); Keyboard.undo();
@ -699,14 +785,12 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
...INITIALIZED_IMAGE_PROPS, type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true, isDeleted: true,
...DEER_IMAGE_DIMENSIONS, ...smileyImageDimensions,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...SMILEY_IMAGE_DIMENSIONS,
}), }),
]); ]);
@ -715,49 +799,14 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
...INITIALIZED_IMAGE_PROPS, type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false, isDeleted: false,
...DEER_IMAGE_DIMENSIONS, ...smileyImageDimensions,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...SMILEY_IMAGE_DIMENSIONS,
}), }),
]); ]);
};
it("should create new history entry on image drag&drop", async () => {
await setupImageTest();
await API.drop(
(
await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
])
).map((file) => ({
kind: "file",
file,
})),
);
await assertImageTest();
});
it("should create new history entry on image paste", async () => {
await setupImageTest();
document.dispatchEvent(
createPasteEvent({
files: await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
}),
);
await assertImageTest();
}); });
it("should create new history entry on embeddable link paste", async () => { it("should create new history entry on embeddable link paste", async () => {

View File

@ -1,115 +0,0 @@
import { randomId, reseed } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
import * as blobModule from "../data/blob";
import * as filesystemModule from "../data/filesystem";
import { Excalidraw } from "../index";
import { createPasteEvent } from "../clipboard";
import { API } from "./helpers/api";
import { mockMultipleHTMLImageElements } from "./helpers/mocks";
import { UI } from "./helpers/ui";
import { GlobalTestState, render, waitFor } from "./test-utils";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
const { h } = window;
export const setupImageTest = async (
sizes: { width: number; height: number }[],
) => {
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
h.state.height = 1000;
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
};
describe("image insertion", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() =>
Promise.resolve(randomId() as FileId),
);
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
});
const setup = () =>
setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
const assert = async () => {
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
});
// Not placed on top of each other
const dimensionsSet = new Set(h.elements.map((el) => `${el.x}-${el.y}`));
expect(dimensionsSet.size).toEqual(h.elements.length);
};
it("should eventually initialize all dropped images", async () => {
await setup();
const files = await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]);
await API.drop(files.map((file) => ({ kind: "file", file })));
await assert();
});
it("should eventually initialize all pasted images", async () => {
await setup();
document.dispatchEvent(
createPasteEvent({
files: await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
}),
);
await assert();
});
it("should eventually initialize all images added through image tool", async () => {
await setup();
const fileOpenSpy = vi.spyOn(filesystemModule, "fileOpen");
fileOpenSpy.mockImplementation(
async () =>
await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
);
UI.clickTool("image");
await assert();
});
});

View File

@ -1,5 +1,5 @@
import { act, queryByTestId } from "@testing-library/react"; import { queryByTestId } from "@testing-library/react";
import React from "react"; import { act } from "@testing-library/react";
import { vi } from "vitest"; import { vi } from "vitest";
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common"; import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
@ -13,9 +13,11 @@ import { serializeLibraryAsJSON } from "../data/json";
import { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { fireEvent, render, togglePopover, waitFor } from "./test-utils";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { UI } from "./helpers/ui"; import { UI } from "./helpers/ui";
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils"; import { getCloneByOrigId } from "./test-utils";
import type { LibraryItem, LibraryItems } from "../types"; import type { LibraryItem, LibraryItems } from "../types";
@ -56,13 +58,9 @@ describe("library", () => {
it("import library via drag&drop", async () => { it("import library via drag&drop", async () => {
expect(await h.app.library.getLatestLibrary()).toEqual([]); expect(await h.app.library.getLatestLibrary()).toEqual([]);
await API.drop([ await API.drop(
{ await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
kind: "file", );
type: MIME_TYPES.excalidrawlib,
file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
},
]);
await waitFor(async () => { await waitFor(async () => {
expect(await h.app.library.getLatestLibrary()).toEqual([ expect(await h.app.library.getLatestLibrary()).toEqual([
{ {
@ -79,13 +77,11 @@ describe("library", () => {
it("drop library item onto canvas", async () => { it("drop library item onto canvas", async () => {
expect(h.elements).toEqual([]); expect(h.elements).toEqual([]);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);
await API.drop([ await API.drop(
{ new Blob([serializeLibraryAsJSON(libraryItems)], {
kind: "string",
value: serializeLibraryAsJSON(libraryItems),
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
@ -117,20 +113,23 @@ describe("library", () => {
}, },
}); });
await API.drop([ await API.drop(
{ new Blob(
kind: "string", [
value: serializeLibraryAsJSON([ serializeLibraryAsJSON([
{ {
id: "item1", id: "item1",
status: "published", status: "published",
elements: [rectangle, text, arrow], elements: [rectangle, text, arrow],
created: 1, created: 1,
}, },
]), ]),
type: MIME_TYPES.excalidrawlib, ],
}, {
]); type: MIME_TYPES.excalidrawlib,
},
),
);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual( expect(h.elements).toEqual(
@ -173,13 +172,11 @@ describe("library", () => {
created: 1, created: 1,
}; };
await API.drop([ await API.drop(
{ new Blob([serializeLibraryAsJSON([item1, item1])], {
kind: "string",
value: serializeLibraryAsJSON([item1, item1]),
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -198,13 +195,11 @@ describe("library", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
expect(h.elements).toEqual([]); expect(h.elements).toEqual([]);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);
await API.drop([ await API.drop(
{ new Blob([serializeLibraryAsJSON(libraryItems)], {
kind: "string",
value: serializeLibraryAsJSON(libraryItems),
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
}, }),
]); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
@ -222,12 +217,13 @@ describe("library menu", () => {
const libraryButton = container.querySelector(".sidebar-trigger"); const libraryButton = container.querySelector(".sidebar-trigger");
fireEvent.click(libraryButton!); fireEvent.click(libraryButton!);
fireEvent.click( togglePopover("Library menu");
queryByTestId( // fireEvent.click(
container.querySelector(".layer-ui__library")!, // queryByTestId(
"dropdown-menu-button", // container.querySelector(".layer-ui__library")!,
)!, // "dropdown-menu-button",
); // )!,
// );
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!); fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
const libraryItems = parseLibraryJSON(await libraryJSONPromise); const libraryItems = parseLibraryJSON(await libraryJSONPromise);

View File

@ -2,26 +2,46 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = ` exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div <div
class="dropdown-menu" aria-labelledby="radix-:r65:"
aria-orientation="vertical"
class="dropdown-menu main-menu-content"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu" data-testid="dropdown-menu"
dir="ltr"
id="radix-:r66:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
> >
<div <div
class="Island dropdown-menu-container" class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;" style="--padding: 1; z-index: 2;"
> >
<button <div
class="dropdown-menu-item dropdown-menu-item-base" class="radix-menu-item"
type="button" data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
> >
<div <button
class="dropdown-menu-item__icon" class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
/> type="button"
<div
class="dropdown-menu-item__text"
> >
Click me <div
</div> class="dropdown-menu-item__icon"
</button> />
<div
class="dropdown-menu-item__text"
>
Click me
</div>
</button>
</div>
<a <a
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
href="https://plus.excalidraw.com/blog" href="https://plus.excalidraw.com/blog"
@ -46,301 +66,361 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
custom menu item custom menu item
</button> </button>
</div> </div>
<button <div
aria-label="Help" class="radix-menu-item"
class="dropdown-menu-item dropdown-menu-item-base" data-orientation="vertical"
data-testid="help-menu-item" data-radix-collection-item=""
title="Help" role="menuitem"
type="button" tabindex="-1"
> >
<div <button
class="dropdown-menu-item__icon" aria-label="Help"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
> >
<svg <div
aria-hidden="true" class="dropdown-menu-item__icon"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
> >
<g <svg
stroke-width="1.5" aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
> >
<path <g
d="M0 0h24v24H0z" stroke-width="1.5"
fill="none" >
stroke="none" <path
/> d="M0 0h24v24H0z"
<circle fill="none"
cx="12" stroke="none"
cy="12" />
r="9" <circle
/> cx="12"
<line cy="12"
x1="12" r="9"
x2="12" />
y1="17" <line
y2="17.01" x1="12"
/> x2="12"
<path y1="17"
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4" y2="17.01"
/> />
</g> <path
</svg> d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
</div> />
<div </g>
class="dropdown-menu-item__text" </svg>
> </div>
Help <div
</div> class="dropdown-menu-item__text"
<div >
class="dropdown-menu-item__shortcut" Help
> </div>
? <div
</div> class="dropdown-menu-item__shortcut"
</button> >
?
</div>
</button>
</div>
</div> </div>
</div> </div>
`; `;
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = ` exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
<div <div
class="dropdown-menu" aria-labelledby="radix-:rq:"
aria-orientation="vertical"
class="dropdown-menu main-menu-content"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu" data-testid="dropdown-menu"
dir="ltr"
id="radix-:rr:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
> >
<div <div
class="Island dropdown-menu-container" class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;" style="--padding: 1; z-index: 2;"
> >
<button <div
aria-label="Open" class="radix-menu-item"
class="dropdown-menu-item dropdown-menu-item-base" data-orientation="vertical"
data-testid="load-button" data-radix-collection-item=""
title="Open" role="menuitem"
type="button" tabindex="-1"
> >
<div <button
class="dropdown-menu-item__icon" aria-label="Open"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="load-button"
title="Open"
type="button"
> >
<svg <div
aria-hidden="true" class="dropdown-menu-item__icon"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<path <svg
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z" aria-hidden="true"
stroke-width="1.25" class=""
/> fill="none"
</svg> focusable="false"
</div> role="img"
<div stroke="currentColor"
class="dropdown-menu-item__text" stroke-linecap="round"
> stroke-linejoin="round"
Open viewBox="0 0 20 20"
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+O
</div>
</button>
<button
aria-label="Save to..."
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="json-export-button"
title="Save to..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Save to...
</div>
</button>
<button
aria-label="Export image..."
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="image-export-button"
title="Export image..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.25"
> >
<path <path
d="M0 0h24v24H0z" d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
fill="none" stroke-width="1.25"
stroke="none"
/> />
<path </svg>
d="M15 8h.01" </div>
/> <div
<path class="dropdown-menu-item__text"
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
/>
<path
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
/>
<path
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
/>
<path
d="M19 16v6"
/>
<path
d="M22 19l-3 3l-3 -3"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Export image...
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+Shift+E
</div>
</button>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
> >
<g Open
stroke-width="1.5" </div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+O
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Save to..."
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="json-export-button"
title="Save to..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<path <path
d="M0 0h24v24H0z" d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
fill="none" stroke-width="1.25"
stroke="none"
/> />
<circle </svg>
cx="12" </div>
cy="12" <div
r="9" class="dropdown-menu-item__text"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
<button
aria-label="Reset the canvas"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<path Save to...
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5" </div>
stroke-width="1.25" </button>
/> </div>
</svg> <div
</div> class="radix-menu-item"
<div data-orientation="vertical"
class="dropdown-menu-item__text" data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Export image..."
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="image-export-button"
title="Export image..."
type="button"
> >
Reset the canvas <div
</div> class="dropdown-menu-item__icon"
</button> >
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.25"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M15 8h.01"
/>
<path
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
/>
<path
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
/>
<path
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
/>
<path
d="M19 16v6"
/>
<path
d="M22 19l-3 3l-3 -3"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Export image...
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+Shift+E
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Help"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Reset the canvas"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Reset the canvas
</div>
</button>
</div>
<div <div
style="height: 1px; margin: .5rem 0px;" style="height: 1px; margin: .5rem 0px;"
/> />
@ -473,45 +553,53 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div <div
style="height: 1px; margin: .5rem 0px;" style="height: 1px; margin: .5rem 0px;"
/> />
<button <div
aria-label="Dark mode" class="radix-menu-item"
class="dropdown-menu-item dropdown-menu-item-base" data-orientation="vertical"
data-testid="toggle-dark-mode" data-radix-collection-item=""
title="Dark mode" role="menuitem"
type="button" tabindex="-1"
> >
<div <button
class="dropdown-menu-item__icon" aria-label="Dark mode"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="toggle-dark-mode"
title="Dark mode"
type="button"
> >
<svg <div
aria-hidden="true" class="dropdown-menu-item__icon"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
> >
<path <svg
clip-rule="evenodd" aria-hidden="true"
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z" class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor" stroke="currentColor"
/> stroke-linecap="round"
</svg> stroke-linejoin="round"
</div> viewBox="0 0 20 20"
<div >
class="dropdown-menu-item__text" <path
> clip-rule="evenodd"
Dark mode d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
</div> stroke="currentColor"
<div />
class="dropdown-menu-item__shortcut" </svg>
> </div>
Shift+Alt+D <div
</div> class="dropdown-menu-item__text"
</button> >
Dark mode
</div>
<div
class="dropdown-menu-item__shortcut"
>
Shift+Alt+D
</div>
</button>
</div>
<div <div
style="margin-top: 0.5rem;" style="margin-top: 0.5rem;"
> >
@ -522,6 +610,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
Canvas background Canvas background
</div> </div>
<div <div
id="canvas-bg-color-picker-container"
style="padding: 0px 0.625rem;" style="padding: 0px 0.625rem;"
> >
<div> <div>
@ -593,7 +682,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
style="width: 1px; height: 100%; margin: 0px auto;" style="width: 1px; height: 100%; margin: 0px auto;"
/> />
<button <button
aria-controls="radix-:r0:" aria-controls="radix-:r12:"
aria-expanded="false" aria-expanded="false"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-label="Canvas background" aria-label="Canvas background"

View File

@ -352,10 +352,6 @@ export interface AppState {
| "elementBackground" | "elementBackground"
| "elementStroke" | "elementStroke"
| "fontFamily" | "fontFamily"
| "compactTextProperties"
| "compactStrokeStyles"
| "compactOtherProperties"
| "compactArrowProperties"
| null; | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: openDialog:
@ -446,9 +442,6 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements // as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map // and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true }; lockedMultiSelections: { [groupId: string]: true };
/** properties sidebar mode - determines whether to show compact or complete sidebar */
stylesPanelMode: "compact" | "full";
} }
export type SearchMatch = { export type SearchMatch = {

View File

@ -7,7 +7,6 @@ import {
getFontString, getFontString,
getFontFamilyString, getFontFamilyString,
isTestEnv, isTestEnv,
MIME_TYPES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -46,7 +45,7 @@ import type {
import { actionSaveToActiveFile } from "../actions"; import { actionSaveToActiveFile } from "../actions";
import { parseDataTransferEvent } from "../clipboard"; import { parseClipboard } from "../clipboard";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
@ -333,14 +332,12 @@ export const textWysiwyg = ({
if (onChange) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
const textItem = (await parseDataTransferEvent(event)).findByType( const clipboardData = await parseClipboard(event, true);
MIME_TYPES.text, if (!clipboardData.text) {
);
if (!textItem) {
return; return;
} }
const text = normalizeText(textItem.value); const data = normalizeText(clipboardData.text);
if (!text) { if (!data) {
return; return;
} }
const container = getContainerElement( const container = getContainerElement(
@ -358,7 +355,7 @@ export const textWysiwyg = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
const wrappedText = wrapText( const wrappedText = wrapText(
`${editable.value}${text}`, `${editable.value}${data}`,
font, font,
getBoundTextMaxWidth(container, boundTextElement), getBoundTextMaxWidth(container, boundTextElement),
); );
@ -542,7 +539,6 @@ export const textWysiwyg = ({
if (isDestroyed) { if (isDestroyed) {
return; return;
} }
isDestroyed = true; isDestroyed = true;
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
@ -626,24 +622,14 @@ export const textWysiwyg = ({
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
const isPropertiesContent =
(target instanceof HTMLElement || target instanceof SVGElement) &&
!!(target as Element).closest(".properties-content");
const inShapeActionsMenu =
(target instanceof HTMLElement || target instanceof SVGElement) &&
(!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
!!(target as Element).closest(".compact-shape-actions-island"));
setTimeout(() => { setTimeout(() => {
// If we interacted within shape actions menu or its popovers/triggers,
// keep submit disabled and don't steal focus back to textarea.
if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
return;
}
// Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
editable.focus();
// case: clicking on the same property → no change → no update → no focus
if (!isPropertiesTrigger) {
editable.focus();
}
}); });
}; };
@ -666,7 +652,6 @@ export const textWysiwyg = ({
event.preventDefault(); event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event); app.handleCanvasPanUsingWheelOrSpaceDrag(event);
} }
temporarilyDisableSubmit(); temporarilyDisableSubmit();
return; return;
} }
@ -674,20 +659,15 @@ export const textWysiwyg = ({
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
const isPropertiesContent =
(target instanceof HTMLElement || target instanceof SVGElement) &&
!!(target as Element).closest(".properties-content");
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
(event.target.closest( event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`, `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) || ) &&
event.target.closest(".compact-shape-actions-island")) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isPropertiesTrigger || isPropertiesTrigger
isPropertiesContent
) { ) {
temporarilyDisableSubmit(); temporarilyDisableSubmit();
} else if ( } else if (

View File

@ -177,19 +177,3 @@ export function lineSegmentIntersectionPoints<
return candidate; return candidate;
} }
export function lineSegmentsDistance<Point extends GlobalPoint | LocalPoint>(
s1: LineSegment<Point>,
s2: LineSegment<Point>,
): number {
if (lineSegmentIntersectionPoints(s1, s2)) {
return 0;
}
return Math.min(
distanceToLineSegment(s1[0], s2),
distanceToLineSegment(s1[1], s2),
distanceToLineSegment(s2[0], s1),
distanceToLineSegment(s2[1], s1),
);
}

View File

@ -100,7 +100,6 @@ exports[`exportToSvg > with default arguments 1`] = `
"open": false, "open": false,
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full",
"suggestedBindings": [], "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,

5195
yarn.lock

File diff suppressed because it is too large Load Diff