Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Di
80c42cc5ea add design doc 2025-08-22 18:03:30 +10:00
60 changed files with 979 additions and 3263 deletions

View File

@ -0,0 +1,101 @@
## Excalidraw Hierarchical Model Plan
### Background & Goals
Introduce a fully in-memory hierarchical (tree) model on top of the existing flat `elements[]` storage for more efficient complex operations (queries, selection, collision), while keeping flat arrays as the persistence/collab projection. Gradually move to tree-first edits with flat projection.
### Capabilities To Preserve
- z-index via fractional indices
- add/remove to frame
- group/ungroup
- bound texts (containerId)
- arrow bindings (start/endBinding)
- history (undo/redo) and collab (delta broadcast)
- load/save the flat array
### Existing Reusable Capabilities
- Deltas & History: `element/src/delta.ts` (ElementsDelta/AppStateDelta/StoreDelta), `excalidraw/history.ts` (HistoryDelta), auto rebind, text bbox redraw, z-index normalization.
- Store & Snapshot: `element/src/store.ts` provides commit levels, batching, and delta emission.
- Scene & Relationships: `element/src/Scene.ts`, `element/src/frame.ts`, `element/src/groups.ts` for frames and groups logic.
- Rendering: `excalidraw/renderer/staticScene.ts` with order by fractional index.
- Restore/Import: `excalidraw/data/restore.ts`.
### Data Model & Invariants
- Node types: `ElementNode`, logical `GroupNode` (id=groupId), `FrameNode` (bound to frame element). `Table*Node` reserved.
- Parent priority: container > group (deep→shallow) > frame > root; single parent per node.
- Groups must not span multiple frames.
- Drawing order remains by fractional index; the tree offers structural and sibling-order views only.
### Flat→Tree Build (`buildFromFlat`)
- Input: `elements[]`/`elementsMap` (optionally including deleted).
- Output: `{ nodesById, roots, orderHints, diagnostics }`.
- Rules:
- Bound text attaches to its container; groups form deep→shallow parent chains from `groupIds`; frame parent from `frameId`; otherwise root.
- Sibling order: ascending by the minimum `index` across the nodes represented elements.
- Diagnostics: cross-frame groups, invalid container, cycles, missing refs (error/warn).
### Tree → Flat Projection (`flattenToArray`)
- Input: tree, optional "apply recommended reorder".
- Output: `{ nextFieldsByElementId, reorderIntent? }`.
- Rules:
- `frameId` from nearest frame ancestor; `groupIds` nearest→farthest; `containerId` from nearest container.
- Do not change draw order by default; any reordering is applied by the caller via `Scene.insert*` and `syncMovedIndices`.
### Operations Mapping (Tree edits → Flat deltas)
- z-index: sibling reordering → index deltas; normalized with `syncMovedIndices`.
- Frame membership: reparent to `FrameNode`/root → `frameId` updates; cross-frame groups disallowed.
- Group/ungroup: modify `GroupNode` structure → update `groupIds` chains.
- Bound text: reparent to container → update `containerId`/`boundElements`; text bbox redraw handled by `ElementsDelta`.
- Arrow binding: does not change parentage; only update start/endBinding; `ElementsDelta` handles rebind/unbind.
### History & Collab
- Transactional edits on the tree via `HierarchyManager.begin/commit/rollback`; commit projects to a minimal flat diff, wrapped as `StoreDelta`, and submitted via `Store.scheduleMicroAction` (IMMEDIATELY).
- Undo/redo uses `HistoryDelta`; replay re-emits flat deltas for sync.
- Collab remains flat-delta based; peers rebuild the tree deterministically from flats.
### Rendering Strategy
- Add a tree-backed rendering adapter beside `renderStaticScene` behind a feature flag, preserving draw-order semantics (fractional index). In the short term, use the tree for selection/collision pruning (frame → group → element).
### Challenges & Risks
- Cross-frame group handling (block or guided fix).
- Reorder consistency (tree sibling order vs fractional index).
- Collab conflicts (use `ElementsDelta.applyLatestChanges`).
- Performance (build O(n), queries O(1)/O(k)); cache/incremental via `sceneNonce`.
- Test coverage (round-trip, collab equivalence, history replay, deep groups/large frames/binding chains).
### Phased Plan
- Phase 0 Rules & Contracts
- Lock invariants and priorities; define diagnostics (error/warn).
- Phase 1 Pure functions & Validation
- Implement `buildFromFlat`, `flattenToArray`, `validateIntegrity`; cache by `sceneNonce`; add round-trip tests.
- Phase 2 Read-only integration
- Tree-backed selection and collision pruning; measure wins.
- Phase 3 Parallel render adapter
- Tree render adapter (flag) with preserved order semantics.
- Phase 4 Projection & Transactions
- `HierarchyManager.begin/commit/rollback`; commit→`StoreDelta`→Store.
- Phase 5 Migrate operations
- Frame membership and group/ungroup → tree+projection; then bound text; optional z-index reorder intent.
- Phase 6 Extensions & Tables
- Introduce `Table*Node` (in-memory first, then projection), with validation and UI.
### Success Criteria
- Correctness: same flat → same tree; unchanged structure round-trip no-ops; existing operations equivalent.
- History/Collab: still record and broadcast minimal flat deltas; deterministic tree on peers.
- Performance: selection/collision candidate reduction on large scenes; build/query latency targets met.
- Rollback: feature flag to fall back to legacy path at any time.
### Next Steps
- Finalize invariants and IO contracts; implement `buildFromFlat`/`flattenToArray` and `validateIntegrity`; add roundtrip and failure-case tests; prototype read-only integration and render adapter.

View File

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

View File

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

View File

@ -28,9 +28,11 @@ export const isBrave = () =>
export const isMobile = export const isMobile =
isIOS || isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test( /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent, navigator.userAgent.toLowerCase(),
) || ) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform); /android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@ -129,7 +131,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 +261,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 +344,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;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -534,5 +524,3 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;

View File

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

View File

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

View File

@ -359,12 +359,6 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@ -712,7 +706,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ) => {
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@ -747,15 +741,8 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1); const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2); const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal( const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@ -814,19 +801,10 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = globalUpdatedPoints.at( const secondToLastPoint =
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2), globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
); const thirdToLastPoint =
const thirdToLastPoint = globalUpdatedPoints.at( globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading); const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal( const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint, thirdToLastPoint,
@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>

View File

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

View File

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

View File

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

View File

@ -35,7 +35,6 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@ -226,16 +225,7 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( scene.mutateElement(textElement, { angle });
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@ -426,15 +416,9 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, { scene.mutateElement(boundText, {
x, x: boundText.x + (rotatedCX - cx),
y, y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }

View File

@ -10,12 +10,12 @@ import {
invariant, invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
x = x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
} }
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y }; return { x, y };
}; };

View File

@ -1,14 +1,13 @@
import { getLineHeight } from "@excalidraw/common"; import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement"; } from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -123,7 +123,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
stylesPanelMode: "full",
}; };
}; };
@ -248,7 +247,6 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true }, lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false }, activeLockedId: { browser: false, export: false, server: false },
stylesPanelMode: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

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

View File

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

View File

@ -91,120 +91,3 @@
} }
} }
} }
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
--default-button-size: 2rem;
.compact-action-button {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
}
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}

View File

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

View File

@ -41,6 +41,9 @@ import {
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES, MAX_ALLOWED_FILE_BYTES,
MIME_TYPES, MIME_TYPES,
MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT,
MQ_RIGHT_SIDEBAR_MIN_WIDTH, MQ_RIGHT_SIDEBAR_MIN_WIDTH,
POINTER_BUTTON, POINTER_BUTTON,
ROUNDNESS, ROUNDNESS,
@ -97,12 +100,8 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
isMobile,
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD,
isMobileOrTablet,
MQ_MAX_MOBILE,
MQ_MIN_TABLET,
MQ_MAX_TABLET,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -237,7 +236,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 +322,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 +344,7 @@ import {
generateIdFromFile, generateIdFromFile,
getDataURL, getDataURL,
getDataURL_sync, getDataURL_sync,
getFileFromEvent,
ImageURLToFile, ImageURLToFile,
isImageFileHandle, isImageFileHandle,
isSupportedImageFile, isSupportedImageFile,
@ -438,7 +431,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";
@ -538,7 +531,6 @@ export const useExcalidrawActionManager = () =>
let didTapTwice: boolean = false; let didTapTwice: boolean = false;
let tappedTwiceTimer = 0; let tappedTwiceTimer = 0;
let firstTapPosition: { x: number; y: number } | null = null;
let isHoldingSpace: boolean = false; let isHoldingSpace: boolean = false;
let isPanning: boolean = false; let isPanning: boolean = false;
let isDraggingScrollBar: boolean = false; let isDraggingScrollBar: boolean = false;
@ -667,7 +659,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 +2412,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 +2435,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 +2473,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;
@ -3003,7 +2989,6 @@ class App extends React.Component<AppProps, AppState> {
private static resetTapTwice() { private static resetTapTwice() {
didTapTwice = false; didTapTwice = false;
firstTapPosition = null;
} }
private onTouchStart = (event: TouchEvent) => { private onTouchStart = (event: TouchEvent) => {
@ -3014,13 +2999,6 @@ class App extends React.Component<AppProps, AppState> {
if (!didTapTwice) { if (!didTapTwice) {
didTapTwice = true; didTapTwice = true;
if (event.touches.length === 1) {
firstTapPosition = {
x: event.touches[0].clientX,
y: event.touches[0].clientY,
};
}
clearTimeout(tappedTwiceTimer); clearTimeout(tappedTwiceTimer);
tappedTwiceTimer = window.setTimeout( tappedTwiceTimer = window.setTimeout(
App.resetTapTwice, App.resetTapTwice,
@ -3028,29 +3006,15 @@ class App extends React.Component<AppProps, AppState> {
); );
return; return;
} }
// insert text only if we tapped twice with a single finger
// insert text only if we tapped twice with a single finger at approximately the same position
// event.touches.length === 1 will also prevent inserting text when user's zooming // event.touches.length === 1 will also prevent inserting text when user's zooming
if (didTapTwice && event.touches.length === 1 && firstTapPosition) { if (didTapTwice && event.touches.length === 1) {
const touch = event.touches[0]; const touch = event.touches[0];
const distance = pointDistance( // @ts-ignore
pointFrom(touch.clientX, touch.clientY), this.handleCanvasDoubleClick({
pointFrom(firstTapPosition.x, firstTapPosition.y), clientX: touch.clientX,
); clientY: touch.clientY,
});
// only create text if the second tap is within the threshold of the first tap
// this prevents accidental text creation during dragging/selection
if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) {
// end lasso trail and deselect elements just in case
this.lassoTrail.endPath();
this.deselectElements();
// @ts-ignore
this.handleCanvasDoubleClick({
clientX: touch.clientX,
clientY: touch.clientY,
});
}
didTapTwice = false; didTapTwice = false;
clearTimeout(tappedTwiceTimer); clearTimeout(tappedTwiceTimer);
} }
@ -3078,166 +3042,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 +3067,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 +3119,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 +3407,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 +4782,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 +6643,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 +6658,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 +7219,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 +7818,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 +7846,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 +7863,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 +8473,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 +10191,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 +10258,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 +10472,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 +10543,13 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (fileItems.length > 0) { if (file) {
const { file, fileHandle } = fileItems[0]; // Attempt to parse an excalidraw/excalidrawlib file
if (file) { await this.loadFileToCanvas(file, fileHandle);
// Attempt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle);
}
} }
const textItem = dataTransferList.findByType(MIME_TYPES.text); if (event.dataTransfer?.types?.includes("text/plain")) {
const text = event.dataTransfer?.getData("text");
if (textItem) {
const text = textItem.value;
if ( if (
text && text &&
embeddableURLValidator(text, this.props.validateEmbeddable) && embeddableURLValidator(text, this.props.validateEmbeddable) &&

View File

@ -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 {
@ -86,16 +80,6 @@
} }
} }
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;

View File

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

View File

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

View File

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

View File

@ -90,8 +90,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("");
@ -188,42 +187,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 +194,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect: wrappedOnSelect, onSelect,
onHover, onHover,
onClose, onClose,
}); });
@ -241,7 +204,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], [hoveredFont, filteredFonts, onSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@ -277,7 +240,7 @@ export const FontPickerList = React.memo(
// 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) => { onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value)); onSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@ -319,24 +282,9 @@ export const FontPickerList = React.memo(
className="properties-content" className="properties-content"
container={container} container={container}
style={{ width: "15rem" }} style={{ width: "15rem" }}
onClose={() => { onClose={onClose}
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave} onPointerLeave={onLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch <QuickSearch
ref={inputRef} ref={inputRef}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -118,17 +118,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 +396,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">
@ -2293,48 +2269,3 @@ export const elementLinkIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const resizeIcon = createIcon(
<g strokeWidth={1.5}>
<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 d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />
<path d="M16 6l4 0" />
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 12l2 0" />
<path d="M10 12l10 0" />
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 18l11 0" />
<path d="M19 18l1 0" />
</g>,
tablerIconProps,
);
export const backgroundIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 10l4 -4" />
<path d="M6 14l8 -8" />
<path d="M6 18l12 -12" />
<path d="M10 18l8 -8" />
<path d="M14 18l4 -4" />
</g>,
tablerIconProps,
);
export const strokeIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="6" width="12" height="12" fill="none" />
</g>,
tablerIconProps,
);

View File

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

View File

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

View File

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

View File

@ -387,10 +387,7 @@ export const restoreElement = (
elbowed: true, elbowed: true,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
fixedSegments: fixedSegments: element.fixedSegments,
element.fixedSegments?.length && base.points.length >= 4
? element.fixedSegments
: null,
startIsSpecial: element.startIsSpecial, startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial, endIsSpecial: element.endIsSpecial,
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -56,13 +56,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 +75,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 +111,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 +170,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 +193,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" })]);
}); });

View File

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

View File

@ -7,7 +7,6 @@ import {
getFontString, getFontString,
getFontFamilyString, getFontFamilyString,
isTestEnv, isTestEnv,
MIME_TYPES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -46,7 +45,7 @@ import type {
import { actionSaveToActiveFile } from "../actions"; import { actionSaveToActiveFile } from "../actions";
import { parseDataTransferEvent } from "../clipboard"; import { parseClipboard } from "../clipboard";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
@ -216,12 +215,11 @@ export const textWysiwyg = ({
); );
app.scene.mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, { height: targetContainerHeight });
} else { } else {
const { x, y } = computeBoundTextPosition( const { y } = computeBoundTextPosition(
container, container,
updatedTextElement as ExcalidrawTextElementWithContainer, updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap, elementsMap,
); );
coordX = x;
coordY = y; coordY = y;
} }
} }
@ -333,14 +331,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 +354,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 +538,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 +621,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 +651,6 @@ export const textWysiwyg = ({
event.preventDefault(); event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event); app.handleCanvasPanUsingWheelOrSpaceDrag(event);
} }
temporarilyDisableSubmit(); temporarilyDisableSubmit();
return; return;
} }
@ -674,20 +658,15 @@ export const textWysiwyg = ({
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
const isPropertiesContent =
(target instanceof HTMLElement || target instanceof SVGElement) &&
!!(target as Element).closest(".properties-content");
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
(event.target.closest( event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`, `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) || ) &&
event.target.closest(".compact-shape-actions-island")) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isPropertiesTrigger || isPropertiesTrigger
isPropertiesContent
) { ) {
temporarilyDisableSubmit(); temporarilyDisableSubmit();
} else if ( } else if (

View File

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

View File

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