Compare commits
33 Commits
master
...
barnabasmo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dae6edf2 | ||
|
|
ecbaeb1701 | ||
|
|
40c5c743b1 | ||
|
|
5082142b36 | ||
|
|
74cb027fd7 | ||
|
|
bc09ac757f | ||
|
|
66e347f7d2 | ||
|
|
d5974e66b2 | ||
|
|
2a1b22a504 | ||
|
|
b3d241ba7f | ||
|
|
8ff1ac8097 | ||
|
|
d967123383 | ||
|
|
05cd1a79cc | ||
|
|
bd08bdf4c7 | ||
|
|
011b268dde | ||
|
|
b6a7f05761 | ||
|
|
8787c7d8cf | ||
|
|
6d21d7cab1 | ||
|
|
c9df3e143b | ||
|
|
5b11660cc0 | ||
|
|
bf0b2965e6 | ||
|
|
8f8b6e7144 | ||
|
|
b63d17045e | ||
|
|
70d48d5472 | ||
|
|
097000a2b7 | ||
|
|
461661afc6 | ||
|
|
c88f3c84eb | ||
|
|
7d791b86f8 | ||
|
|
e615056302 | ||
|
|
14ad745d00 | ||
|
|
9c3ff73a73 | ||
|
|
79cf71cccb | ||
|
|
e094b8b539 |
@ -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" }}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
|
|||||||
},
|
},
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
"viewport": {
|
"viewport": {
|
||||||
"isLandscape": true,
|
"isLandscape": false,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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[]) {
|
||||||
|
|||||||
@ -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"}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -18,7 +18,6 @@ export {
|
|||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
actionChangeArrowProperties,
|
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -69,7 +69,6 @@ export type ActionName =
|
|||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
| "changeArrowProperties"
|
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
|
|||||||
@ -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 = <
|
||||||
|
|||||||
@ -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="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">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="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">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="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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",
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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) &&
|
||||||
|
|||||||
@ -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 })}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
151
packages/excalidraw/components/FontPicker/FontPickerListItem.tsx
Normal file
151
packages/excalidraw/components/FontPicker/FontPickerListItem.tsx
Normal 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;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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")]}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
||||||
@ -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";
|
||||||
@ -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";
|
||||||
@ -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";
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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" })]);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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: "😀" }),
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
export const DEER_IMAGE_DIMENSIONS = {
|
|
||||||
width: 318,
|
|
||||||
height: 335,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SMILEY_IMAGE_DIMENSIONS = {
|
|
||||||
width: 56,
|
|
||||||
height: 77,
|
|
||||||
};
|
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
export const INITIALIZED_IMAGE_PROPS = {
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
};
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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] || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user