Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118fd7bafa | ||
|
|
21f492fb13 |
@ -33,7 +33,6 @@ const ExcalidrawScope = {
|
|||||||
initialData,
|
initialData,
|
||||||
useI18n: ExcalidrawComp.useI18n,
|
useI18n: ExcalidrawComp.useI18n,
|
||||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExcalidrawScope;
|
export default ExcalidrawScope;
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
|
TITLE_TIMEOUT,
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@ -498,6 +499,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleTimeout = setTimeout(
|
||||||
|
() => (document.title = APP_NAME),
|
||||||
|
TITLE_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@ -588,6 +594,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
clearTimeout(titleTimeout);
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||||
import { throttleRAF } from "@excalidraw/common";
|
import { throttleRAF } from "@excalidraw/common";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLineSegment,
|
isLineSegment,
|
||||||
@ -18,8 +18,6 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||||
@ -115,6 +113,10 @@ const _debugRenderer = (
|
|||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
const context = bootstrapCanvas({
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
@ -312,29 +314,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
|||||||
interface DebugCanvasProps {
|
interface DebugCanvasProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
ref?: React.Ref<HTMLCanvasElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||||
({ appState, scale }, ref) => {
|
const { width, height } = appState;
|
||||||
const { width, height } = appState;
|
|
||||||
|
|
||||||
return (
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
<canvas
|
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||||
style={{
|
ref,
|
||||||
width,
|
() => canvasRef.current,
|
||||||
height,
|
[canvasRef],
|
||||||
position: "absolute",
|
);
|
||||||
zIndex: 2,
|
|
||||||
pointerEvents: "none",
|
return (
|
||||||
}}
|
<canvas
|
||||||
width={width * scale}
|
style={{
|
||||||
height={height * scale}
|
width,
|
||||||
ref={ref}
|
height,
|
||||||
>
|
position: "absolute",
|
||||||
Debug Canvas
|
zIndex: 2,
|
||||||
</canvas>
|
pointerEvents: "none",
|
||||||
);
|
}}
|
||||||
},
|
width={width * scale}
|
||||||
);
|
height={height * scale}
|
||||||
|
ref={canvasRef}
|
||||||
|
>
|
||||||
|
Debug Canvas
|
||||||
|
</canvas>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DebugCanvas;
|
export default DebugCanvas;
|
||||||
|
|||||||
@ -259,9 +259,7 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
deleteInvisibleElements: true,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@ -258,16 +258,11 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw Whiteboard</title>
|
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
|
|||||||
},
|
},
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
"viewport": {
|
"viewport": {
|
||||||
"isLandscape": true,
|
"isLandscape": false,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,20 +18,13 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|||||||
export const isSafari =
|
export const isSafari =
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
export const isIOS =
|
export const isIOS =
|
||||||
/iPad|iPhone/i.test(navigator.platform) ||
|
/iPad|iPhone/.test(navigator.platform) ||
|
||||||
// iPadOS 13+
|
// iPadOS 13+
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
// keeping function so it can be mocked in test
|
// keeping function so it can be mocked in test
|
||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||||
|
|
||||||
export const isMobile =
|
|
||||||
isIOS ||
|
|
||||||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
|
||||||
navigator.userAgent,
|
|
||||||
) ||
|
|
||||||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
|
||||||
|
|
||||||
export const supportsResizeObserver =
|
export const supportsResizeObserver =
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
@ -43,7 +36,6 @@ export const APP_NAME = "Excalidraw";
|
|||||||
// (happens a lot with fast clicks with the text tool)
|
// (happens a lot with fast clicks with the text tool)
|
||||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
|
||||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
@ -129,7 +121,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 +251,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 +334,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 +514,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;
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -164,14 +164,9 @@ export class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(elements: ElementsMapOrArray | null = null) {
|
||||||
elements: ElementsMapOrArray | null = null,
|
|
||||||
options?: {
|
|
||||||
skipValidation?: true;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (elements) {
|
if (elements) {
|
||||||
this.replaceAllElements(elements, options);
|
this.replaceAllElements(elements);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,19 +263,12 @@ export class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(
|
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||||
nextElements: ElementsMapOrArray,
|
|
||||||
options?: {
|
|
||||||
skipValidation?: true;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
const _nextElements = toArray(nextElements);
|
const _nextElements = toArray(nextElements);
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
if (!options?.skipValidation) {
|
validateIndicesThrottled(_nextElements);
|
||||||
validateIndicesThrottled(_nextElements);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(_nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { getSelectedElementsByGroup } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
@ -18,12 +16,11 @@ export const alignElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
appState: Readonly<AppState>,
|
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
scene.getNonDeletedElementsMap(),
|
elementsMap,
|
||||||
appState,
|
|
||||||
);
|
);
|
||||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
|||||||
|
|
||||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
import { StoreSnapshot } from "./store";
|
|
||||||
|
|
||||||
import { Scene } from "./Scene";
|
import { Scene } from "./Scene";
|
||||||
|
|
||||||
|
import { StoreSnapshot } from "./store";
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "./binding";
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@ -150,27 +150,13 @@ export class Delta<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges two deltas into a new one.
|
|
||||||
*/
|
|
||||||
public static merge<T>(
|
|
||||||
delta1: Delta<T>,
|
|
||||||
delta2: Delta<T>,
|
|
||||||
delta3: Delta<T> = Delta.empty(),
|
|
||||||
) {
|
|
||||||
return Delta.create(
|
|
||||||
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
|
||||||
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges deleted and inserted object partials.
|
* Merges deleted and inserted object partials.
|
||||||
*/
|
*/
|
||||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||||
prev: T,
|
prev: T,
|
||||||
added: T,
|
added: T,
|
||||||
removed: T = {} as T,
|
removed: T,
|
||||||
) {
|
) {
|
||||||
const cloned = { ...prev };
|
const cloned = { ...prev };
|
||||||
|
|
||||||
@ -510,11 +496,6 @@ export interface DeltaContainer<T> {
|
|||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
/**
|
|
||||||
* Squashes the current delta with the given one.
|
|
||||||
*/
|
|
||||||
squash(delta: DeltaContainer<T>): this;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether all `Delta`s are empty.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
@ -522,11 +503,7 @@ export interface DeltaContainer<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
|
||||||
return new AppStateDelta(delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
@ -557,124 +534,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return new AppStateDelta(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public squash(delta: AppStateDelta): this {
|
|
||||||
if (delta.isEmpty()) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
|
||||||
this.delta.deleted.selectedElementIds ?? {},
|
|
||||||
delta.delta.deleted.selectedElementIds ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
|
||||||
this.delta.inserted.selectedElementIds ?? {},
|
|
||||||
delta.delta.inserted.selectedElementIds ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
|
||||||
this.delta.deleted.selectedGroupIds ?? {},
|
|
||||||
delta.delta.deleted.selectedGroupIds ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
|
||||||
this.delta.inserted.selectedGroupIds ?? {},
|
|
||||||
delta.delta.inserted.selectedGroupIds ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
|
||||||
this.delta.deleted.lockedMultiSelections ?? {},
|
|
||||||
delta.delta.deleted.lockedMultiSelections ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
|
||||||
this.delta.inserted.lockedMultiSelections ?? {},
|
|
||||||
delta.delta.inserted.lockedMultiSelections ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedInserted: Partial<ObservedAppState> = {};
|
|
||||||
const mergedDeleted: Partial<ObservedAppState> = {};
|
|
||||||
|
|
||||||
if (
|
|
||||||
Object.keys(mergedDeletedSelectedElementIds).length ||
|
|
||||||
Object.keys(mergedInsertedSelectedElementIds).length
|
|
||||||
) {
|
|
||||||
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
|
||||||
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
|
||||||
Object.keys(mergedInsertedSelectedGroupIds).length
|
|
||||||
) {
|
|
||||||
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
|
||||||
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
|
||||||
Object.keys(mergedInsertedLockedMultiSelections).length
|
|
||||||
) {
|
|
||||||
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
|
||||||
mergedInserted.lockedMultiSelections =
|
|
||||||
mergedInsertedLockedMultiSelections;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.delta = Delta.merge(
|
|
||||||
this.delta,
|
|
||||||
delta.delta,
|
|
||||||
Delta.create(mergedDeleted, mergedInserted),
|
|
||||||
);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
): [AppState, boolean] {
|
): [AppState, boolean] {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
selectedElementIds: deletedSelectedElementIds = {},
|
selectedElementIds: removedSelectedElementIds = {},
|
||||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
selectedGroupIds: removedSelectedGroupIds = {},
|
||||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
|
||||||
} = this.delta.deleted;
|
} = this.delta.deleted;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedElementIds: insertedSelectedElementIds = {},
|
selectedElementIds: addedSelectedElementIds = {},
|
||||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
selectedGroupIds: addedSelectedGroupIds = {},
|
||||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
selectedLinearElementId,
|
||||||
selectedLinearElement: insertedSelectedLinearElement,
|
editingLinearElementId,
|
||||||
...directlyApplicablePartial
|
...directlyApplicablePartial
|
||||||
} = this.delta.inserted;
|
} = this.delta.inserted;
|
||||||
|
|
||||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||||
appState.selectedElementIds,
|
appState.selectedElementIds,
|
||||||
insertedSelectedElementIds,
|
addedSelectedElementIds,
|
||||||
deletedSelectedElementIds,
|
removedSelectedElementIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
insertedSelectedGroupIds,
|
addedSelectedGroupIds,
|
||||||
deletedSelectedGroupIds,
|
removedSelectedGroupIds,
|
||||||
);
|
|
||||||
|
|
||||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
|
||||||
appState.lockedMultiSelections,
|
|
||||||
insertedLockedMultiSelections,
|
|
||||||
deletedLockedMultiSelections,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedLinearElement =
|
const selectedLinearElement =
|
||||||
insertedSelectedLinearElement &&
|
selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
||||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
|
||||||
? new LinearElementEditor(
|
? new LinearElementEditor(
|
||||||
nextElements.get(
|
nextElements.get(
|
||||||
insertedSelectedLinearElement.elementId,
|
selectedLinearElementId,
|
||||||
|
) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
nextElements,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const editingLinearElement =
|
||||||
|
editingLinearElementId && nextElements.has(editingLinearElementId)
|
||||||
|
? new LinearElementEditor(
|
||||||
|
nextElements.get(
|
||||||
|
editingLinearElementId,
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
nextElements,
|
nextElements,
|
||||||
insertedSelectedLinearElement.isEditing,
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -683,11 +589,14 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
...directlyApplicablePartial,
|
...directlyApplicablePartial,
|
||||||
selectedElementIds: mergedSelectedElementIds,
|
selectedElementIds: mergedSelectedElementIds,
|
||||||
selectedGroupIds: mergedSelectedGroupIds,
|
selectedGroupIds: mergedSelectedGroupIds,
|
||||||
lockedMultiSelections: mergedLockedMultiSelections,
|
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
typeof insertedSelectedLinearElement !== "undefined"
|
typeof selectedLinearElementId !== "undefined"
|
||||||
? selectedLinearElement
|
? selectedLinearElement // element was either inserted or deleted
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement, // otherwise assign what we had before
|
||||||
|
editingLinearElement:
|
||||||
|
typeof editingLinearElementId !== "undefined"
|
||||||
|
? editingLinearElement // element was either inserted or deleted
|
||||||
|
: appState.editingLinearElement, // otherwise assign what we had before
|
||||||
};
|
};
|
||||||
|
|
||||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||||
@ -816,53 +725,52 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedLinearElement":
|
case "selectedLinearElementId":
|
||||||
const nextLinearElement = nextAppState[key];
|
case "editingLinearElementId":
|
||||||
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
if (!nextLinearElement) {
|
if (!linearElement) {
|
||||||
// previously there was a linear element (assuming visible), now there is none
|
// previously there was a linear element (assuming visible), now there is none
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
const element = nextElements.get(nextLinearElement.elementId);
|
const element = nextElements.get(linearElement.elementId);
|
||||||
|
|
||||||
if (element && !element.isDeleted) {
|
if (element && !element.isDeleted) {
|
||||||
// previously there wasn't a linear element, now there is one which is visible
|
// previously there wasn't a linear element, now there is one which is visible
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
// there was assigned a linear element now, but it's deleted
|
// there was assigned a linear element now, but it's deleted
|
||||||
nextAppState[key] = null;
|
nextAppState[appStateKey] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "lockedMultiSelections":
|
case "lockedMultiSelections": {
|
||||||
const prevLockedUnits = prevAppState[key] || {};
|
const prevLockedUnits = prevAppState[key] || {};
|
||||||
const nextLockedUnits = nextAppState[key] || {};
|
const nextLockedUnits = nextAppState[key] || {};
|
||||||
|
|
||||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
|
||||||
// hence instead we should check whether elements are actually visible,
|
|
||||||
// so that once these changes are applied they actually result in a visible change to the user
|
|
||||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "activeLockedId":
|
}
|
||||||
|
case "activeLockedId": {
|
||||||
const prevHitLockedId = prevAppState[key] || null;
|
const prevHitLockedId = prevAppState[key] || null;
|
||||||
const nextHitLockedId = nextAppState[key] || null;
|
const nextHitLockedId = nextAppState[key] || null;
|
||||||
|
|
||||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
|
||||||
// hence instead we should check whether elements are actually visible,
|
|
||||||
// so that once these changes are applied they actually result in a visible change to the user
|
|
||||||
if (prevHitLockedId !== nextHitLockedId) {
|
if (prevHitLockedId !== nextHitLockedId) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
}
|
||||||
|
default: {
|
||||||
assertNever(
|
assertNever(
|
||||||
key,
|
key,
|
||||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -870,6 +778,20 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return visibleDifferenceFlag.value;
|
return visibleDifferenceFlag.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static convertToAppStateKey(
|
||||||
|
key: keyof Pick<
|
||||||
|
ObservedElementsAppState,
|
||||||
|
"selectedLinearElementId" | "editingLinearElementId"
|
||||||
|
>,
|
||||||
|
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
||||||
|
switch (key) {
|
||||||
|
case "selectedLinearElementId":
|
||||||
|
return "selectedLinearElement";
|
||||||
|
case "editingLinearElementId":
|
||||||
|
return "editingLinearElement";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static filterSelectedElements(
|
private static filterSelectedElements(
|
||||||
selectedElementIds: AppState["selectedElementIds"],
|
selectedElementIds: AppState["selectedElementIds"],
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
@ -934,7 +856,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
editingGroupId,
|
editingGroupId,
|
||||||
selectedGroupIds,
|
selectedGroupIds,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
selectedLinearElement,
|
editingLinearElementId,
|
||||||
|
selectedLinearElementId,
|
||||||
croppingElementId,
|
croppingElementId,
|
||||||
lockedMultiSelections,
|
lockedMultiSelections,
|
||||||
activeLockedId,
|
activeLockedId,
|
||||||
@ -988,6 +911,12 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
"lockedMultiSelections",
|
"lockedMultiSelections",
|
||||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||||
);
|
);
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"activeLockedId",
|
||||||
|
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||||
@ -1016,13 +945,12 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
|||||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||||
|
|
||||||
export type ApplyToOptions = {
|
export type ApplyToOptions = {
|
||||||
excludedProperties?: Set<keyof ElementPartial>;
|
excludedProperties: Set<keyof ElementPartial>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApplyToFlags = {
|
type ApplyToFlags = {
|
||||||
containsVisibleDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
containsZindexDifference: boolean;
|
containsZindexDifference: boolean;
|
||||||
applyDirection: "forward" | "backward" | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1111,27 +1039,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
inserted,
|
inserted,
|
||||||
}: Delta<ElementPartial>) =>
|
}: Delta<ElementPartial>) =>
|
||||||
!!(
|
!!(
|
||||||
|
deleted.version &&
|
||||||
|
inserted.version &&
|
||||||
// versions are required integers
|
// versions are required integers
|
||||||
(
|
Number.isInteger(deleted.version) &&
|
||||||
Number.isInteger(deleted.version) &&
|
Number.isInteger(inserted.version) &&
|
||||||
Number.isInteger(inserted.version) &&
|
// versions should be positive, zero included
|
||||||
// versions should be positive, zero included
|
deleted.version >= 0 &&
|
||||||
deleted.version! >= 0 &&
|
inserted.version >= 0 &&
|
||||||
inserted.version! >= 0 &&
|
// versions should never be the same
|
||||||
// versions should never be the same
|
deleted.version !== inserted.version
|
||||||
deleted.version !== inserted.version
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private static satisfiesUniqueInvariants = (
|
|
||||||
elementsDelta: ElementsDelta,
|
|
||||||
id: string,
|
|
||||||
) => {
|
|
||||||
const { added, removed, updated } = elementsDelta;
|
|
||||||
// it's required that there is only one unique delta type per element
|
|
||||||
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
elementsDelta: ElementsDelta,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
@ -1140,7 +1059,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (
|
if (
|
||||||
!this.satisfiesCommmonInvariants(delta) ||
|
!this.satisfiesCommmonInvariants(delta) ||
|
||||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
|
||||||
!satifiesSpecialInvariants(delta)
|
!satifiesSpecialInvariants(delta)
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
@ -1177,7 +1095,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const nextElement = nextElements.get(prevElement.id);
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
if (!nextElement) {
|
if (!nextElement) {
|
||||||
const deleted = { ...prevElement } as ElementPartial;
|
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
@ -1191,11 +1109,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!prevElement.isDeleted) {
|
removed[prevElement.id] = delta;
|
||||||
removed[prevElement.id] = delta;
|
|
||||||
} else {
|
|
||||||
updated[prevElement.id] = delta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1211,6 +1125,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
...nextElement,
|
...nextElement,
|
||||||
|
isDeleted: false,
|
||||||
} as ElementPartial;
|
} as ElementPartial;
|
||||||
|
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
@ -1219,12 +1134,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ignore updates which would "delete" already deleted element
|
added[nextElement.id] = delta;
|
||||||
if (!nextElement.isDeleted) {
|
|
||||||
added[nextElement.id] = delta;
|
|
||||||
} else {
|
|
||||||
updated[nextElement.id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1253,7 +1163,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
updated[nextElement.id] = delta;
|
// making sure there are at least some changes
|
||||||
|
if (!Delta.isEmpty(delta)) {
|
||||||
|
updated[nextElement.id] = delta;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1268,8 +1181,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@ -1388,7 +1301,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||||
options?: ApplyToOptions,
|
options: ApplyToOptions = {
|
||||||
|
excludedProperties: new Set(),
|
||||||
|
},
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = new Map(elements) as SceneElementsMap;
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
@ -1396,28 +1311,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const flags: ApplyToFlags = {
|
const flags: ApplyToFlags = {
|
||||||
containsVisibleDifference: false,
|
containsVisibleDifference: false,
|
||||||
containsZindexDifference: false,
|
containsZindexDifference: false,
|
||||||
applyDirection: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsDelta.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
elements,
|
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
flags,
|
|
||||||
options,
|
options,
|
||||||
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas(this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas(this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas(this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(
|
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
elements,
|
|
||||||
nextElements,
|
|
||||||
flags.applyDirection,
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||||
changedElements = new Map([
|
changedElements = new Map([
|
||||||
@ -1441,15 +1350,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
nextElements = ElementsDelta.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||||
|
// we also don't have a scene on the server
|
||||||
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
|
const tempScene = new Scene(nextElements);
|
||||||
|
|
||||||
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
|
// Need ordered nextElements to avoid z-index binding issues
|
||||||
|
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@ -1464,113 +1380,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public squash(delta: ElementsDelta): this {
|
|
||||||
if (delta.isEmpty()) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { added, removed, updated } = delta;
|
|
||||||
|
|
||||||
const mergeBoundElements = (
|
|
||||||
prevDelta: Delta<ElementPartial>,
|
|
||||||
nextDelta: Delta<ElementPartial>,
|
|
||||||
) => {
|
|
||||||
const mergedDeletedBoundElements =
|
|
||||||
Delta.mergeArrays(
|
|
||||||
prevDelta.deleted.boundElements ?? [],
|
|
||||||
nextDelta.deleted.boundElements ?? [],
|
|
||||||
undefined,
|
|
||||||
(x) => x.id,
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const mergedInsertedBoundElements =
|
|
||||||
Delta.mergeArrays(
|
|
||||||
prevDelta.inserted.boundElements ?? [],
|
|
||||||
nextDelta.inserted.boundElements ?? [],
|
|
||||||
undefined,
|
|
||||||
(x) => x.id,
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!mergedDeletedBoundElements.length &&
|
|
||||||
!mergedInsertedBoundElements.length
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Delta.create(
|
|
||||||
{
|
|
||||||
boundElements: mergedDeletedBoundElements,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
boundElements: mergedInsertedBoundElements,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [id, nextDelta] of Object.entries(added)) {
|
|
||||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
|
||||||
|
|
||||||
if (!prevDelta) {
|
|
||||||
this.added[id] = nextDelta;
|
|
||||||
} else {
|
|
||||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
|
||||||
delete this.removed[id];
|
|
||||||
delete this.updated[id];
|
|
||||||
|
|
||||||
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, nextDelta] of Object.entries(removed)) {
|
|
||||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
|
||||||
|
|
||||||
if (!prevDelta) {
|
|
||||||
this.removed[id] = nextDelta;
|
|
||||||
} else {
|
|
||||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
|
||||||
delete this.added[id];
|
|
||||||
delete this.updated[id];
|
|
||||||
|
|
||||||
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, nextDelta] of Object.entries(updated)) {
|
|
||||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
|
||||||
|
|
||||||
if (!prevDelta) {
|
|
||||||
this.updated[id] = nextDelta;
|
|
||||||
} else {
|
|
||||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
|
||||||
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
|
||||||
|
|
||||||
if (prevDelta === this.added[id]) {
|
|
||||||
this.added[id] = updatedDelta;
|
|
||||||
} else if (prevDelta === this.removed[id]) {
|
|
||||||
this.removed[id] = updatedDelta;
|
|
||||||
} else {
|
|
||||||
this.updated[id] = updatedDelta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
|
||||||
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
|
||||||
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
|
||||||
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static createApplier =
|
private static createApplier =
|
||||||
(
|
(
|
||||||
prevElements: SceneElementsMap,
|
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"],
|
snapshot: StoreSnapshot["elements"],
|
||||||
|
options: ApplyToOptions,
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
options?: ApplyToOptions,
|
|
||||||
) =>
|
) =>
|
||||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const getElement = ElementsDelta.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
@ -1583,26 +1398,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const nextElement = ElementsDelta.applyDelta(
|
const newElement = ElementsDelta.applyDelta(
|
||||||
element,
|
element,
|
||||||
delta,
|
delta,
|
||||||
flags,
|
|
||||||
options,
|
options,
|
||||||
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
nextElements.set(nextElement.id, nextElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(nextElement.id, nextElement);
|
acc.set(newElement.id, newElement);
|
||||||
|
|
||||||
if (!flags.applyDirection) {
|
|
||||||
const prevElement = prevElements.get(id);
|
|
||||||
|
|
||||||
if (prevElement) {
|
|
||||||
flags.applyDirection =
|
|
||||||
prevElement.version > nextElement.version
|
|
||||||
? "backward"
|
|
||||||
: "forward";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@ -1647,8 +1451,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static applyDelta(
|
private static applyDelta(
|
||||||
element: OrderedExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
delta: Delta<ElementPartial>,
|
delta: Delta<ElementPartial>,
|
||||||
|
options: ApplyToOptions,
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
options?: ApplyToOptions,
|
|
||||||
) {
|
) {
|
||||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||||
|
|
||||||
@ -1662,7 +1466,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.excludedProperties?.has(key)) {
|
if (options.excludedProperties.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1702,7 +1506,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
delta.deleted.index !== delta.inserted.index;
|
delta.deleted.index !== delta.inserted.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElementWith(element, directlyApplicablePartial, true);
|
return newElementWith(element, directlyApplicablePartial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1742,7 +1546,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
applyDirection: "forward" | "backward" = "forward",
|
|
||||||
) {
|
) {
|
||||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
const updater = (
|
const updater = (
|
||||||
@ -1754,36 +1557,21 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElement = prevElements.get(element.id);
|
|
||||||
const nextVersion =
|
|
||||||
applyDirection === "forward"
|
|
||||||
? nextElement.version + 1
|
|
||||||
: nextElement.version - 1;
|
|
||||||
|
|
||||||
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
|
||||||
|
|
||||||
let affectedElement: OrderedExcalidrawElement;
|
let affectedElement: OrderedExcalidrawElement;
|
||||||
|
|
||||||
if (prevElement === nextElement) {
|
if (prevElements.get(element.id) === nextElement) {
|
||||||
// create the new element instance in case we didn't modify the element yet
|
// create the new element instance in case we didn't modify the element yet
|
||||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||||
affectedElement = newElementWith(
|
affectedElement = newElementWith(
|
||||||
nextElement,
|
nextElement,
|
||||||
{
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
...elementUpdates,
|
|
||||||
version: nextVersion,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
affectedElement = mutateElement(nextElement, nextElements, {
|
affectedElement = mutateElement(
|
||||||
...elementUpdates,
|
nextElement,
|
||||||
// don't modify the version further, if it's already different
|
nextElements,
|
||||||
version:
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
prevElement?.version !== nextElement.version
|
);
|
||||||
? nextElement.version
|
|
||||||
: nextVersion,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
@ -1821,12 +1609,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||||
this.squash(
|
// technically we could do better here if perf. would become an issue
|
||||||
// technically we could do better here if perf. would become an issue
|
const { added, removed, updated } = ElementsDelta.calculate(
|
||||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
prevAffectedElements,
|
||||||
|
nextAffectedElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(added)) {
|
||||||
|
this.added[id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(removed)) {
|
||||||
|
this.removed[id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, delta] of Object.entries(updated)) {
|
||||||
|
this.updated[id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1888,31 +1689,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static redrawElements(
|
|
||||||
nextElements: SceneElementsMap,
|
|
||||||
changedElements: Map<string, OrderedExcalidrawElement>,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
|
||||||
// we also don't have a scene on the server
|
|
||||||
// so we are creating a temp scene just to query and mutate elements
|
|
||||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
|
||||||
|
|
||||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
|
||||||
|
|
||||||
// needs ordered nextElements to avoid z-index binding issues
|
|
||||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Couldn't redraw elements`, e);
|
|
||||||
|
|
||||||
if (isTestEnv() || isDevEnv()) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
return nextElements;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static redrawTextBoundingBoxes(
|
private static redrawTextBoundingBoxes(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
@ -1967,7 +1743,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
for (const element of changed.values()) {
|
for (const element of changed.values()) {
|
||||||
if (!element.isDeleted && isBindableElement(element)) {
|
if (!element.isDeleted && isBindableElement(element)) {
|
||||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
changedElements: changed,
|
changedElements: changed,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
import { getSelectedElementsByGroup } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
@ -16,7 +14,6 @@ export const distributeElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
distribution: Distribution,
|
distribution: Distribution,
|
||||||
appState: Readonly<AppState>,
|
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const [start, mid, end, extent] =
|
const [start, mid, end, extent] =
|
||||||
distribution.axis === "x"
|
distribution.axis === "x"
|
||||||
@ -24,11 +21,7 @@ export const distributeElements = (
|
|||||||
: (["minY", "midY", "maxY", "height"] as const);
|
: (["minY", "midY", "maxY", "height"] as const);
|
||||||
|
|
||||||
const bounds = getCommonBoundingBox(selectedElements);
|
const bounds = getCommonBoundingBox(selectedElements);
|
||||||
const groups = getSelectedElementsByGroup(
|
const groups = getMaximumGroups(selectedElements, elementsMap)
|
||||||
selectedElements,
|
|
||||||
elementsMap,
|
|
||||||
appState,
|
|
||||||
)
|
|
||||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||||
|
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
|||||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||||
|
|
||||||
const RE_YOUTUBE =
|
const RE_YOUTUBE =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||||
|
|
||||||
const RE_VIMEO =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
@ -56,35 +56,6 @@ const RE_REDDIT =
|
|||||||
const RE_REDDIT_EMBED =
|
const RE_REDDIT_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||||
|
|
||||||
const parseYouTubeTimestamp = (url: string): number => {
|
|
||||||
let timeParam: string | null | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
|
||||||
timeParam =
|
|
||||||
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
|
||||||
} catch (error) {
|
|
||||||
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
|
||||||
timeParam = timeMatch?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!timeParam) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\d+$/.test(timeParam)) {
|
|
||||||
return parseInt(timeParam, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
|
||||||
if (!timeMatch) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
|
||||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALLOWED_DOMAINS = new Set([
|
const ALLOWED_DOMAINS = new Set([
|
||||||
"youtube.com",
|
"youtube.com",
|
||||||
"youtu.be",
|
"youtu.be",
|
||||||
@ -142,8 +113,7 @@ export const getEmbedLink = (
|
|||||||
let aspectRatio = { w: 560, h: 840 };
|
let aspectRatio = { w: 560, h: 840 };
|
||||||
const ytLink = link.match(RE_YOUTUBE);
|
const ytLink = link.match(RE_YOUTUBE);
|
||||||
if (ytLink?.[2]) {
|
if (ytLink?.[2]) {
|
||||||
const startTime = parseYouTubeTimestamp(originalLink);
|
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||||
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
|
||||||
const isPortrait = link.includes("shorts");
|
const isPortrait = link.includes("shorts");
|
||||||
type = "video";
|
type = "video";
|
||||||
switch (ytLink[1]) {
|
switch (ytLink[1]) {
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
import { isBoundToContainer } from "./typeChecks";
|
|
||||||
|
|
||||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -404,78 +402,3 @@ export const getNewGroupIdsForDuplication = (
|
|||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|
||||||
// given a list of selected elements, return the element grouped by their immediate group selected state
|
|
||||||
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
|
||||||
export const getSelectedElementsByGroup = (
|
|
||||||
selectedElements: ExcalidrawElement[],
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
): ExcalidrawElement[][] => {
|
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
|
||||||
const unboundElements = selectedElements.filter(
|
|
||||||
(element) => !isBoundToContainer(element),
|
|
||||||
);
|
|
||||||
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
|
||||||
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
|
||||||
|
|
||||||
// helper function to add an element to the elements map
|
|
||||||
const addToElementsMap = (element: ExcalidrawElement) => {
|
|
||||||
// elements
|
|
||||||
const currentElementMembers = elements.get(element.id) || [];
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
currentElementMembers.push(boundTextElement);
|
|
||||||
}
|
|
||||||
elements.set(element.id, [...currentElementMembers, element]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// helper function to add an element to the groups map
|
|
||||||
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
|
||||||
// groups
|
|
||||||
const currentGroupMembers = groups.get(groupId) || [];
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
currentGroupMembers.push(boundTextElement);
|
|
||||||
}
|
|
||||||
groups.set(groupId, [...currentGroupMembers, element]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// helper function to handle the case where a single group is selected
|
|
||||||
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
|
||||||
// their nested grouping order
|
|
||||||
const handleSingleSelectedGroupCase = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
selectedGroupId: GroupId,
|
|
||||||
) => {
|
|
||||||
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
|
||||||
const nestedGroupCount = element.groupIds.slice(
|
|
||||||
0,
|
|
||||||
indexOfSelectedGroupId,
|
|
||||||
).length;
|
|
||||||
return nestedGroupCount > 0
|
|
||||||
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
|
||||||
: addToElementsMap(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAllInSameGroup = selectedElements.every((element) =>
|
|
||||||
isSelectedViaGroup(appState, element),
|
|
||||||
);
|
|
||||||
|
|
||||||
unboundElements.forEach((element) => {
|
|
||||||
const selectedGroupId = getSelectedGroupIdForElement(
|
|
||||||
element,
|
|
||||||
appState.selectedGroupIds,
|
|
||||||
);
|
|
||||||
if (!selectedGroupId) {
|
|
||||||
addToElementsMap(element);
|
|
||||||
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
|
||||||
handleSingleSelectedGroupCase(element, selectedGroupId);
|
|
||||||
} else {
|
|
||||||
addToGroupsMap(element, selectedGroupId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -149,12 +149,10 @@ export class LinearElementEditor {
|
|||||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||||
public readonly elbowed: boolean;
|
public readonly elbowed: boolean;
|
||||||
public readonly customLineAngle: number | null;
|
public readonly customLineAngle: number | null;
|
||||||
public readonly isEditing: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
isEditing: boolean = false,
|
|
||||||
) {
|
) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@ -189,7 +187,6 @@ export class LinearElementEditor {
|
|||||||
this.segmentMidPointHoveredCoords = null;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||||
this.customLineAngle = null;
|
this.customLineAngle = null;
|
||||||
this.isEditing = isEditing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -197,7 +194,6 @@ export class LinearElementEditor {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static POINT_HANDLE_SIZE = 10;
|
static POINT_HANDLE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||||
@ -219,14 +215,11 @@ export class LinearElementEditor {
|
|||||||
setState: React.Component<any, AppState>["setState"],
|
setState: React.Component<any, AppState>["setState"],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
) {
|
) {
|
||||||
if (
|
if (!appState.editingLinearElement || !appState.selectionElement) {
|
||||||
!appState.selectedLinearElement?.isEditing ||
|
|
||||||
!appState.selectionElement
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { selectedLinearElement } = appState;
|
const { editingLinearElement } = appState;
|
||||||
const { selectedPointsIndices, elementId } = selectedLinearElement;
|
const { selectedPointsIndices, elementId } = editingLinearElement;
|
||||||
|
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@ -267,8 +260,8 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
selectedLinearElement: {
|
editingLinearElement: {
|
||||||
...selectedLinearElement,
|
...editingLinearElement,
|
||||||
selectedPointsIndices: nextSelectedPoints.length
|
selectedPointsIndices: nextSelectedPoints.length
|
||||||
? nextSelectedPoints
|
? nextSelectedPoints
|
||||||
: null,
|
: null,
|
||||||
@ -486,6 +479,9 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...app.state,
|
...app.state,
|
||||||
|
editingLinearElement: app.state.editingLinearElement
|
||||||
|
? newLinearElementEditor
|
||||||
|
: null,
|
||||||
selectedLinearElement: newLinearElementEditor,
|
selectedLinearElement: newLinearElementEditor,
|
||||||
suggestedBindings,
|
suggestedBindings,
|
||||||
};
|
};
|
||||||
@ -622,7 +618,7 @@ export class LinearElementEditor {
|
|||||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
if (
|
if (
|
||||||
!isElbowArrow(element) &&
|
!isElbowArrow(element) &&
|
||||||
!appState.selectedLinearElement?.isEditing &&
|
!appState.editingLinearElement &&
|
||||||
element.points.length > 2 &&
|
element.points.length > 2 &&
|
||||||
!boundText
|
!boundText
|
||||||
) {
|
) {
|
||||||
@ -688,7 +684,7 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
points.length >= 3 &&
|
points.length >= 3 &&
|
||||||
!appState.selectedLinearElement?.isEditing &&
|
!appState.editingLinearElement &&
|
||||||
!isElbowArrow(element)
|
!isElbowArrow(element)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@ -885,7 +881,7 @@ export class LinearElementEditor {
|
|||||||
segmentMidpoint,
|
segmentMidpoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
|
} else if (event.altKey && appState.editingLinearElement) {
|
||||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
@ -1027,14 +1023,14 @@ export class LinearElementEditor {
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): LinearElementEditor | null {
|
): LinearElementEditor | null {
|
||||||
const appState = app.state;
|
const appState = app.state;
|
||||||
if (!appState.selectedLinearElement?.isEditing) {
|
if (!appState.editingLinearElement) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
|
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return appState.selectedLinearElement;
|
return appState.editingLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@ -1044,12 +1040,10 @@ export class LinearElementEditor {
|
|||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||||
}
|
}
|
||||||
return appState.selectedLinearElement?.lastUncommittedPoint
|
return {
|
||||||
? {
|
...appState.editingLinearElement,
|
||||||
...appState.selectedLinearElement,
|
lastUncommittedPoint: null,
|
||||||
lastUncommittedPoint: null,
|
};
|
||||||
}
|
|
||||||
: appState.selectedLinearElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let newPoint: LocalPoint;
|
let newPoint: LocalPoint;
|
||||||
@ -1073,8 +1067,8 @@ export class LinearElementEditor {
|
|||||||
newPoint = LinearElementEditor.createPointAt(
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||||
? null
|
? null
|
||||||
: app.getEffectiveGridSize(),
|
: app.getEffectiveGridSize(),
|
||||||
@ -1098,7 +1092,7 @@ export class LinearElementEditor {
|
|||||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.selectedLinearElement,
|
...appState.editingLinearElement,
|
||||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1257,12 +1251,12 @@ export class LinearElementEditor {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||||
invariant(
|
invariant(
|
||||||
appState.selectedLinearElement?.isEditing,
|
appState.editingLinearElement,
|
||||||
"Not currently editing a linear element",
|
"Not currently editing a linear element",
|
||||||
);
|
);
|
||||||
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
|
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@ -1324,8 +1318,8 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...appState,
|
...appState,
|
||||||
selectedLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.selectedLinearElement,
|
...appState.editingLinearElement,
|
||||||
selectedPointsIndices: nextSelectedIndices,
|
selectedPointsIndices: nextSelectedIndices,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -1337,9 +1331,8 @@ export class LinearElementEditor {
|
|||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
) {
|
) {
|
||||||
const isUncommittedPoint =
|
const isUncommittedPoint =
|
||||||
app.state.selectedLinearElement?.isEditing &&
|
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||||
app.state.selectedLinearElement?.lastUncommittedPoint ===
|
element.points[element.points.length - 1];
|
||||||
element.points[element.points.length - 1];
|
|
||||||
|
|
||||||
const nextPoints = element.points.filter((_, idx) => {
|
const nextPoints = element.points.filter((_, idx) => {
|
||||||
return !pointIndices.includes(idx);
|
return !pointIndices.includes(idx);
|
||||||
@ -1512,7 +1505,7 @@ export class LinearElementEditor {
|
|||||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!appState.selectedLinearElement?.isEditing &&
|
!appState.editingLinearElement &&
|
||||||
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
import { getCommonBounds } from "./bounds";
|
|
||||||
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "./types";
|
|
||||||
|
|
||||||
// TODO rewrite (mostly vibe-coded)
|
|
||||||
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
|
||||||
elements: TElement[] | TElement[][],
|
|
||||||
centerX: number,
|
|
||||||
centerY: number,
|
|
||||||
padding = 50,
|
|
||||||
): TElement[] => {
|
|
||||||
// Ensure there are elements to position
|
|
||||||
if (!elements || elements.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const res: TElement[] = [];
|
|
||||||
// Normalize input to work with atomic units (groups of elements)
|
|
||||||
// If elements is a flat array, treat each element as its own atomic unit
|
|
||||||
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
|
||||||
? (elements as TElement[][])
|
|
||||||
: (elements as TElement[]).map((element) => [element]);
|
|
||||||
|
|
||||||
// Determine the number of columns for atomic units
|
|
||||||
// A common approach for a "grid-like" layout without specific column constraints
|
|
||||||
// is to aim for a roughly square arrangement.
|
|
||||||
const numUnits = atomicUnits.length;
|
|
||||||
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
|
||||||
|
|
||||||
// Group atomic units into rows based on the calculated number of columns
|
|
||||||
const rows: TElement[][][] = [];
|
|
||||||
for (let i = 0; i < numUnits; i += numColumns) {
|
|
||||||
rows.push(atomicUnits.slice(i, i + numColumns));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate properties for each row (total width, max height)
|
|
||||||
// and the total actual height of all row content.
|
|
||||||
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
|
||||||
const rowProperties = rows.map((rowUnits) => {
|
|
||||||
let rowWidth = 0;
|
|
||||||
let maxUnitHeightInRow = 0;
|
|
||||||
|
|
||||||
const unitBounds = rowUnits.map((unit) => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
|
||||||
return {
|
|
||||||
elements: unit,
|
|
||||||
bounds: [minX, minY, maxX, maxY] as const,
|
|
||||||
width: maxX - minX,
|
|
||||||
height: maxY - minY,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
unitBounds.forEach((unitBound, index) => {
|
|
||||||
rowWidth += unitBound.width;
|
|
||||||
// Add padding between units in the same row, but not after the last one
|
|
||||||
if (index < unitBounds.length - 1) {
|
|
||||||
rowWidth += padding;
|
|
||||||
}
|
|
||||||
if (unitBound.height > maxUnitHeightInRow) {
|
|
||||||
maxUnitHeightInRow = unitBound.height;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
totalGridActualHeight += maxUnitHeightInRow;
|
|
||||||
return {
|
|
||||||
unitBounds,
|
|
||||||
width: rowWidth,
|
|
||||||
maxHeight: maxUnitHeightInRow,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate the total height of the grid including padding between rows
|
|
||||||
const totalGridHeightWithPadding =
|
|
||||||
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
|
||||||
|
|
||||||
// Calculate the starting Y position to center the entire grid vertically around centerY
|
|
||||||
let currentY = centerY - totalGridHeightWithPadding / 2;
|
|
||||||
|
|
||||||
// Position atomic units row by row
|
|
||||||
rowProperties.forEach((rowProp) => {
|
|
||||||
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
|
||||||
|
|
||||||
// Calculate the starting X for the current row to center it horizontally around centerX
|
|
||||||
let currentX = centerX - rowWidth / 2;
|
|
||||||
|
|
||||||
unitBounds.forEach((unitBound) => {
|
|
||||||
// Calculate the offset needed to position this atomic unit
|
|
||||||
const [originalMinX, originalMinY] = unitBound.bounds;
|
|
||||||
const offsetX = currentX - originalMinX;
|
|
||||||
const offsetY = currentY - originalMinY;
|
|
||||||
|
|
||||||
// Apply the offset to all elements in this atomic unit
|
|
||||||
unitBound.elements.forEach((element) => {
|
|
||||||
res.push(
|
|
||||||
newElementWith(element, {
|
|
||||||
x: element.x + offsetX,
|
|
||||||
y: element.y + offsetY,
|
|
||||||
} as ElementUpdate<TElement>),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move X for the next unit in the row
|
|
||||||
currentX += unitBound.width + padding;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move Y to the starting position for the next row
|
|
||||||
// This accounts for the tallest unit in the current row and the inter-row padding
|
|
||||||
currentY += rowMaxHeight + padding;
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
@ -1,14 +1,7 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
import { getStroke } from "perfect-freehand";
|
||||||
|
|
||||||
import {
|
import { isRightAngleRads } from "@excalidraw/math";
|
||||||
type GlobalPoint,
|
|
||||||
isRightAngleRads,
|
|
||||||
lineSegment,
|
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
|
||||||
type Radians,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
@ -21,7 +14,6 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
invariant,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -40,7 +32,7 @@ import type {
|
|||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import { getUncroppedImageElement } from "./cropElement";
|
import { getUncroppedImageElement } from "./cropElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import {
|
||||||
@ -114,11 +106,6 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
|||||||
return element.strokeWidth * 12;
|
return element.strokeWidth * 12;
|
||||||
case "text":
|
case "text":
|
||||||
return element.fontSize / 2;
|
return element.fontSize / 2;
|
||||||
case "arrow":
|
|
||||||
if (element.endArrowhead || element.endArrowhead) {
|
|
||||||
return 40;
|
|
||||||
}
|
|
||||||
return 20;
|
|
||||||
default:
|
default:
|
||||||
return 20;
|
return 20;
|
||||||
}
|
}
|
||||||
@ -1047,66 +1034,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 +1052,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[]) {
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,6 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
|
|
||||||
import type { ApplyToOptions } from "./delta";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
@ -76,9 +74,8 @@ type MicroActionsQueue = (() => void)[];
|
|||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
// for internal use by history
|
// internally used by history
|
||||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
// for public use as part of onIncrement API
|
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[DurableIncrement | EphemeralIncrement]
|
[DurableIncrement | EphemeralIncrement]
|
||||||
>();
|
>();
|
||||||
@ -240,6 +237,7 @@ export class Store {
|
|||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
|
// Notify listeners with the increment
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
@ -552,26 +550,10 @@ export class StoreDelta {
|
|||||||
public static load({
|
public static load({
|
||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
appState: { delta: appStateDelta },
|
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated);
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
const appState = AppStateDelta.create(appStateDelta);
|
|
||||||
|
|
||||||
return new this(id, elements, appState);
|
return new this(id, elements, AppStateDelta.empty());
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Squash the passed deltas into the aggregated delta instance.
|
|
||||||
*/
|
|
||||||
public static squash(...deltas: StoreDelta[]) {
|
|
||||||
const aggregatedDelta = StoreDelta.empty();
|
|
||||||
|
|
||||||
for (const delta of deltas) {
|
|
||||||
aggregatedDelta.elements.squash(delta.elements);
|
|
||||||
aggregatedDelta.appState.squash(delta.appState);
|
|
||||||
}
|
|
||||||
|
|
||||||
return aggregatedDelta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -588,13 +570,9 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
options?: ApplyToOptions,
|
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
const [nextElements, elementsContainVisibleChange] =
|
||||||
elements,
|
delta.elements.applyTo(elements);
|
||||||
StoreSnapshot.empty().elements,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
delta.appState.applyTo(appState, nextElements);
|
delta.appState.applyTo(appState, nextElements);
|
||||||
@ -627,10 +605,6 @@ export class StoreDelta {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
|
||||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
}
|
}
|
||||||
@ -996,7 +970,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
|||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedLinearElement: null,
|
editingLinearElementId: null,
|
||||||
|
selectedLinearElementId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
@ -1015,12 +990,14 @@ export const getObservedAppState = (
|
|||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
selectedLinearElement: appState.selectedLinearElement
|
editingLinearElementId:
|
||||||
? {
|
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
|
||||||
elementId: appState.selectedLinearElement.elementId,
|
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
|
||||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
null,
|
||||||
}
|
selectedLinearElementId:
|
||||||
: null,
|
(appState as AppState).selectedLinearElement?.elementId ??
|
||||||
|
(appState as ObservedAppState).selectedLinearElementId ??
|
||||||
|
null,
|
||||||
};
|
};
|
||||||
|
|
||||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
|||||||
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (elements.length > 1) {
|
if (elements.length > 1) {
|
||||||
|
|||||||
@ -589,424 +589,4 @@ describe("aligning", () => {
|
|||||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createGroupAndSelectInEditGroupMode = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(0, 0);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
// select the first element.
|
|
||||||
// The second rectangle is already reselected because it was the last element created
|
|
||||||
mouse.reset();
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.moveTo(10, 0);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
API.executeAction(actionGroup);
|
|
||||||
mouse.reset();
|
|
||||||
mouse.moveTo(10, 0);
|
|
||||||
mouse.doubleClick();
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.click();
|
|
||||||
mouse.moveTo(100, 100);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
it("aligns elements within a group while in group edit mode correctly to the top", () => {
|
|
||||||
createGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignTop);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
|
|
||||||
createGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignBottom);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
});
|
|
||||||
it("aligns elements within a group while in group edit mode correctly to the left", () => {
|
|
||||||
createGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignLeft);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns elements within a group while in group edit mode correctly to the right", () => {
|
|
||||||
createGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignRight);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
});
|
|
||||||
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
|
|
||||||
createGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignVerticallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
|
||||||
});
|
|
||||||
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
|
|
||||||
createGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignHorizontallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
const createNestedGroupAndSelectInEditGroupMode = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(0, 0);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
// Select the first element.
|
|
||||||
// The second rectangle is already reselected because it was the last element created
|
|
||||||
mouse.reset();
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.moveTo(10, 0);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
API.executeAction(actionGroup);
|
|
||||||
|
|
||||||
mouse.reset();
|
|
||||||
mouse.moveTo(200, 200);
|
|
||||||
// create third element
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(0, 0);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
// third element is already selected, select the initial group and group together
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.moveTo(10, 0);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
API.executeAction(actionGroup);
|
|
||||||
|
|
||||||
// double click to enter edit mode
|
|
||||||
mouse.doubleClick();
|
|
||||||
|
|
||||||
// select nested group and other element within the group
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.moveTo(200, 200);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
it("aligns element and nested group while in group edit mode correctly to the top", () => {
|
|
||||||
createNestedGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignTop);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
|
|
||||||
createNestedGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignBottom);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
});
|
|
||||||
it("aligns element and nested group while in group edit mode correctly to the left", () => {
|
|
||||||
createNestedGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignLeft);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns element and nested group while in group edit mode correctly to the right", () => {
|
|
||||||
createNestedGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignRight);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
});
|
|
||||||
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
|
|
||||||
createNestedGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignVerticallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
|
||||||
});
|
|
||||||
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
|
|
||||||
createNestedGroupAndSelectInEditGroupMode();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignHorizontallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAndSelectSingleGroup = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(0, 0);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
// Select the first element.
|
|
||||||
// The second rectangle is already reselected because it was the last element created
|
|
||||||
mouse.reset();
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.moveTo(10, 0);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
API.executeAction(actionGroup);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("aligns elements within a single-selected group correctly to the top", () => {
|
|
||||||
createAndSelectSingleGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignTop);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group correctly to the bottom", () => {
|
|
||||||
createAndSelectSingleGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignBottom);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group correctly to the left", () => {
|
|
||||||
createAndSelectSingleGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignLeft);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group correctly to the right", () => {
|
|
||||||
createAndSelectSingleGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignRight);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group correctly to the vertical center", () => {
|
|
||||||
createAndSelectSingleGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignVerticallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
|
|
||||||
createAndSelectSingleGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignHorizontallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAndSelectSingleGroupWithNestedGroup = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(0, 0);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
// Select the first element.
|
|
||||||
// The second rectangle is already reselected because it was the last element created
|
|
||||||
mouse.reset();
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.moveTo(10, 0);
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
API.executeAction(actionGroup);
|
|
||||||
|
|
||||||
mouse.reset();
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(200, 200);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
|
|
||||||
// Add group to current selection
|
|
||||||
mouse.restorePosition(10, 0);
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the nested group
|
|
||||||
API.executeAction(actionGroup);
|
|
||||||
};
|
|
||||||
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
|
|
||||||
createAndSelectSingleGroupWithNestedGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignTop);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
|
|
||||||
createAndSelectSingleGroupWithNestedGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignBottom);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
|
|
||||||
createAndSelectSingleGroupWithNestedGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignLeft);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
|
|
||||||
createAndSelectSingleGroupWithNestedGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignRight);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
|
|
||||||
createAndSelectSingleGroupWithNestedGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignVerticallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
|
||||||
});
|
|
||||||
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
|
|
||||||
createAndSelectSingleGroupWithNestedGroup();
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(actionAlignHorizontallyCentered);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -155,10 +155,10 @@ describe("element binding", () => {
|
|||||||
// NOTE this mouse down/up + await needs to be done in order to repro
|
// NOTE this mouse down/up + await needs to be done in order to repro
|
||||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement).not.toBe(null);
|
||||||
mouse.down(0, 0);
|
mouse.down(0, 0);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement).toBe(null);
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
mouse.up();
|
mouse.up();
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
|
|||||||
@ -1,345 +1,13 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
|
|
||||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { LinearElementEditor } from "@excalidraw/element";
|
import type { LinearElementEditor } from "@excalidraw/element";
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
import { AppStateDelta } from "../src/delta";
|
||||||
|
|
||||||
describe("ElementsDelta", () => {
|
|
||||||
describe("elements delta calculation", () => {
|
|
||||||
it("should not throw when element gets removed but was already deleted", () => {
|
|
||||||
const element = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevElements = new Map([[element.id, element]]);
|
|
||||||
const nextElements = new Map();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
ElementsDelta.calculate(prevElements, nextElements),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when adding element as already deleted", () => {
|
|
||||||
const element = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevElements = new Map();
|
|
||||||
const nextElements = new Map([[element.id, element]]);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
ElementsDelta.calculate(prevElements, nextElements),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create updated delta even when there is only version and versionNonce change", () => {
|
|
||||||
const baseElement = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
strokeColor: "#000000",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
});
|
|
||||||
|
|
||||||
const modifiedElement = {
|
|
||||||
...baseElement,
|
|
||||||
version: baseElement.version + 1,
|
|
||||||
versionNonce: baseElement.versionNonce + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create maps for the delta calculation
|
|
||||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
|
||||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
|
||||||
|
|
||||||
// Calculate the delta
|
|
||||||
const delta = ElementsDelta.calculate(
|
|
||||||
prevElements as SceneElementsMap,
|
|
||||||
nextElements as SceneElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(delta).toEqual(
|
|
||||||
ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
[baseElement.id]: Delta.create(
|
|
||||||
{
|
|
||||||
version: baseElement.version,
|
|
||||||
versionNonce: baseElement.versionNonce,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: baseElement.version + 1,
|
|
||||||
versionNonce: baseElement.versionNonce + 1,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("squash", () => {
|
|
||||||
it("should not squash when second delta is empty", () => {
|
|
||||||
const updatedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{ id1: updatedDelta },
|
|
||||||
);
|
|
||||||
const elementsDelta2 = ElementsDelta.empty();
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash mutually exclusive delta types", () => {
|
|
||||||
const addedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const removedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{ id1: addedDelta },
|
|
||||||
{ id2: removedDelta },
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{ id3: updatedDelta },
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
|
||||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
|
||||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash the same delta types", () => {
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2 },
|
|
||||||
{ y: 200, version: 3, versionNonce: 3 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.added.id1).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(elementsDelta.removed.id2).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(elementsDelta.updated.id3).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
|
||||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash different delta types ", () => {
|
|
||||||
// id1: added -> updated => added
|
|
||||||
// id2: removed -> added => added
|
|
||||||
// id3: updated -> removed => removed
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
|
||||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
|
||||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ x: 300, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 301, version: 2, versionNonce: 2 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2 },
|
|
||||||
{ y: 101, version: 3, versionNonce: 3 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.added).toEqual({
|
|
||||||
id1: Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
id2: Delta.create(
|
|
||||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(elementsDelta.removed).toEqual({
|
|
||||||
id3: Delta.create(
|
|
||||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(elementsDelta.updated).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash bound elements", () => {
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
boundElements: [{ id: "t1", type: "text" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 2,
|
|
||||||
versionNonce: 2,
|
|
||||||
boundElements: [{ id: "t2", type: "text" }],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{
|
|
||||||
version: 2,
|
|
||||||
versionNonce: 2,
|
|
||||||
boundElements: [{ id: "a1", type: "arrow" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 3,
|
|
||||||
versionNonce: 3,
|
|
||||||
boundElements: [{ id: "a2", type: "arrow" }],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
|
||||||
{ id: "t1", type: "text" },
|
|
||||||
{ id: "a1", type: "arrow" },
|
|
||||||
]);
|
|
||||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
|
||||||
{ id: "t2", type: "text" },
|
|
||||||
{ id: "a2", type: "arrow" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AppStateDelta", () => {
|
describe("AppStateDelta", () => {
|
||||||
describe("ensure stable delta properties order", () => {
|
describe("ensure stable delta properties order", () => {
|
||||||
it("should maintain stable order for root properties", () => {
|
it("should maintain stable order for root properties", () => {
|
||||||
const name = "untitled scene";
|
const name = "untitled scene";
|
||||||
const selectedLinearElement = {
|
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||||
elementId: "id1" as LinearElementEditor["elementId"],
|
|
||||||
isEditing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const commonAppState = {
|
const commonAppState = {
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
@ -348,7 +16,6 @@ describe("AppStateDelta", () => {
|
|||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
editingLinearElementId: null,
|
editingLinearElementId: null,
|
||||||
selectedLinearElementIsEditing: null,
|
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
};
|
};
|
||||||
@ -356,23 +23,23 @@ describe("AppStateDelta", () => {
|
|||||||
const prevAppState1: ObservedAppState = {
|
const prevAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name: "",
|
name: "",
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
const nextAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name,
|
name,
|
||||||
selectedLinearElement,
|
selectedLinearElementId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
const prevAppState2: ObservedAppState = {
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
name: "",
|
name: "",
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
const nextAppState2: ObservedAppState = {
|
||||||
selectedLinearElement,
|
selectedLinearElementId,
|
||||||
name,
|
name,
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
@ -390,7 +57,8 @@ describe("AppStateDelta", () => {
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@ -436,7 +104,8 @@ describe("AppStateDelta", () => {
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@ -477,97 +146,4 @@ describe("AppStateDelta", () => {
|
|||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("squash", () => {
|
|
||||||
it("should not squash when second delta is empty", () => {
|
|
||||||
const delta = Delta.create(
|
|
||||||
{ name: "untitled scene" },
|
|
||||||
{ name: "titled scene" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const appStateDelta1 = AppStateDelta.create(delta);
|
|
||||||
const appStateDelta2 = AppStateDelta.empty();
|
|
||||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
|
||||||
|
|
||||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(appStateDelta).toBe(appStateDelta1);
|
|
||||||
expect(appStateDelta.delta).toBe(delta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash exclusive properties", () => {
|
|
||||||
const delta1 = Delta.create(
|
|
||||||
{ name: "untitled scene" },
|
|
||||||
{ name: "titled scene" },
|
|
||||||
);
|
|
||||||
const delta2 = Delta.create(
|
|
||||||
{ viewBackgroundColor: "#ffffff" },
|
|
||||||
{ viewBackgroundColor: "#000000" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
|
||||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
|
||||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
|
||||||
|
|
||||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(appStateDelta).toBe(appStateDelta1);
|
|
||||||
expect(appStateDelta.delta).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
|
||||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
|
||||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
|
||||||
{
|
|
||||||
name: "untitled scene",
|
|
||||||
selectedElementIds: { id1: true },
|
|
||||||
selectedGroupIds: {},
|
|
||||||
lockedMultiSelections: { g1: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "titled scene",
|
|
||||||
selectedElementIds: { id2: true },
|
|
||||||
selectedGroupIds: { g1: true },
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
|
||||||
{
|
|
||||||
selectedElementIds: { id3: true },
|
|
||||||
selectedGroupIds: { g1: true },
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
selectedElementIds: { id2: true },
|
|
||||||
selectedGroupIds: { g2: true, g3: true },
|
|
||||||
lockedMultiSelections: { g3: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
|
||||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
|
||||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
|
||||||
|
|
||||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(appStateDelta).toBe(appStateDelta1);
|
|
||||||
expect(appStateDelta.delta).toEqual(
|
|
||||||
Delta.create<Partial<ObservedAppState>>(
|
|
||||||
{
|
|
||||||
name: "untitled scene",
|
|
||||||
selectedElementIds: { id1: true, id3: true },
|
|
||||||
selectedGroupIds: { g1: true },
|
|
||||||
lockedMultiSelections: { g1: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "titled scene",
|
|
||||||
selectedElementIds: { id2: true },
|
|
||||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
|
||||||
lockedMultiSelections: { g3: true },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
import {
|
|
||||||
distributeHorizontally,
|
|
||||||
distributeVertically,
|
|
||||||
} from "@excalidraw/excalidraw/actions";
|
|
||||||
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
unmountComponent,
|
|
||||||
render,
|
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
|
||||||
|
|
||||||
// Scenario: three rectangles that will be distributed with gaps
|
|
||||||
const createAndSelectThreeRectanglesWithGap = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(10, 10);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(300, 300);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
// Last rectangle is selected by default
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.click(0, 10);
|
|
||||||
mouse.click(10, 0);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scenario: three rectangles that will be distributed by their centers
|
|
||||||
const createAndSelectThreeRectanglesWithoutGap = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(10, 10);
|
|
||||||
mouse.up(200, 200);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(200, 200);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
// Last rectangle is selected by default
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.click(0, 10);
|
|
||||||
mouse.click(10, 0);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("distributing", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
unmountComponent();
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
await act(() => {
|
|
||||||
return setLanguage(defaultLang);
|
|
||||||
});
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements horizontally", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithGap();
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
|
||||||
|
|
||||||
API.executeAction(distributeHorizontally);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements vertically", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithGap();
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
|
||||||
|
|
||||||
API.executeAction(distributeVertically);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements horizontally based on their centers", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithoutGap();
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(distributeHorizontally);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements vertically with based on their centers", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithoutGap();
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(distributeVertically);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
import { getEmbedLink } from "../src/embeddable";
|
|
||||||
|
|
||||||
describe("YouTube timestamp parsing", () => {
|
|
||||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
|
||||||
expectedStart: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
|
||||||
expectedStart: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
|
||||||
expectedStart: 150,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach(({ url, expectedStart }) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain(`start=${expectedStart}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse YouTube URLs with timestamp in time format", () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
|
||||||
expectedStart: 90, // 1*60 + 30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
|
||||||
expectedStart: 165, // 2*60 + 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
|
||||||
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
|
||||||
expectedStart: 45,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
|
||||||
expectedStart: 300, // 5*60
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
|
||||||
expectedStart: 7200, // 2*3600
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach(({ url, expectedStart }) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain(`start=${expectedStart}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle YouTube URLs without timestamps", () => {
|
|
||||||
const testCases = [
|
|
||||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
||||||
"https://youtu.be/dQw4w9WgXcQ",
|
|
||||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach((url) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).not.toContain("start=");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle YouTube shorts URLs with timestamps", () => {
|
|
||||||
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain("start=30");
|
|
||||||
}
|
|
||||||
// Shorts should have portrait aspect ratio
|
|
||||||
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle playlist URLs with timestamps", () => {
|
|
||||||
const url =
|
|
||||||
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain("start=60");
|
|
||||||
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle malformed or edge case timestamps", () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
|
||||||
expectedStart: 0, // Invalid timestamp should default to 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
|
||||||
expectedStart: 0, // Empty timestamp should default to 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
|
||||||
expectedStart: 0, // Zero timestamp should be handled
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach(({ url, expectedStart }) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
if (expectedStart === 0) {
|
|
||||||
expect(result.link).not.toContain("start=");
|
|
||||||
} else {
|
|
||||||
expect(result.link).toContain(`start=${expectedStart}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve other URL parameters", () => {
|
|
||||||
const url =
|
|
||||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain("start=90");
|
|
||||||
expect(result.link).toContain("enablejsapi=1");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -136,8 +136,7 @@ describe("Test Linear Elements", () => {
|
|||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
||||||
@ -254,82 +253,75 @@ describe("Test Linear Elements", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
||||||
|
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor via enter (line)", () => {
|
it("should enter line editor via enter (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ctrl+enter alias (to align with arrows)
|
// ctrl+enter alias (to align with arrows)
|
||||||
it("should enter line editor via ctrl+enter (line)", () => {
|
it("should enter line editor via ctrl+enter (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor via ctrl+enter (arrow)", () => {
|
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
});
|
});
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor on ctrl+dblclick (line)", () => {
|
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
});
|
});
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor on dblclick (line)", () => {
|
it("should enter line editor on dblclick (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not enter line editor on dblclick (arrow)", async () => {
|
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||||
|
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
expect(h.state.selectedLinearElement).toBe(null);
|
expect(h.state.editingLinearElement).toEqual(null);
|
||||||
await getTextEditor();
|
await getTextEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -338,12 +330,10 @@ describe("Test Linear Elements", () => {
|
|||||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||||
enterLineEditingMode(arrow);
|
enterLineEditingMode(arrow);
|
||||||
|
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
|
||||||
|
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||||
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
|
|
||||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
@ -377,7 +367,7 @@ describe("Test Linear Elements", () => {
|
|||||||
// drag line from midpoint
|
// drag line from midpoint
|
||||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -479,7 +469,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(startPoint, endPoint);
|
drag(startPoint, endPoint);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@ -547,7 +537,7 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`14`,
|
`16`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@ -598,7 +588,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -639,7 +629,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -687,7 +677,7 @@ describe("Test Linear Elements", () => {
|
|||||||
deletePoint(points[2]);
|
deletePoint(points[2]);
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`17`,
|
`18`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@ -745,7 +735,7 @@ describe("Test Linear Elements", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`14`,
|
`16`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
@ -843,7 +833,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`11`,
|
`12`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -10,8 +10,6 @@ import { alignElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element";
|
import type { Alignment } from "@excalidraw/element";
|
||||||
@ -40,11 +38,7 @@ export const alignActionsPredicate = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return (
|
return (
|
||||||
getSelectedElementsByGroup(
|
selectedElements.length > 1 &&
|
||||||
selectedElements,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
appState as Readonly<AppState>,
|
|
||||||
).length > 1 &&
|
|
||||||
// TODO enable aligning frames when implemented properly
|
// TODO enable aligning frames when implemented properly
|
||||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||||
);
|
);
|
||||||
@ -58,12 +52,7 @@ const alignSelectedElements = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
const updatedElements = alignElements(
|
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||||
selectedElements,
|
|
||||||
alignment,
|
|
||||||
app.scene,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
|
|||||||
@ -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"}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -122,7 +121,7 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
? { ...appState.activeTool, type: "selection" }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@ -495,13 +494,13 @@ export const actionToggleEraserTool = register({
|
|||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
label: "toolBar.eraser",
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: app.defaultSelectionTool,
|
type: "selection",
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@ -531,9 +530,6 @@ export const actionToggleLassoTool = register({
|
|||||||
label: "toolBar.lasso",
|
label: "toolBar.lasso",
|
||||||
icon: LassoIcon,
|
icon: LassoIcon,
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
predicate: (elements, appState, props, app) => {
|
|
||||||
return app.defaultSelectionTool !== "lasso";
|
|
||||||
},
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@ -205,19 +205,16 @@ export const actionDeleteSelected = register({
|
|||||||
icon: TrashIcon,
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.selectedLinearElement;
|
} = appState.editingLinearElement;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const linearElement = LinearElementEditor.getElement(
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
elementId,
|
if (!element) {
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
if (!linearElement) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// case: no point selected → do nothing, as deleting the whole element
|
// case: no point selected → do nothing, as deleting the whole element
|
||||||
@ -228,10 +225,10 @@ export const actionDeleteSelected = register({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// case: deleting all points
|
// case: deleting last remaining point
|
||||||
if (selectedPointsIndices.length >= linearElement.points.length) {
|
if (element.points.length < 2) {
|
||||||
const nextElements = elements.map((el) => {
|
const nextElements = elements.map((el) => {
|
||||||
if (el.id === linearElement.id) {
|
if (el.id === element.id) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@ -242,7 +239,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
selectedLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -255,24 +252,20 @@ export const actionDeleteSelected = register({
|
|||||||
? null
|
? null
|
||||||
: startBindingElement,
|
: startBindingElement,
|
||||||
endBindingElement: selectedPointsIndices?.includes(
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
linearElement.points.length - 1,
|
element.points.length - 1,
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||||
linearElement,
|
|
||||||
app,
|
|
||||||
selectedPointsIndices,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.selectedLinearElement,
|
...appState.editingLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
selectedPointsIndices:
|
selectedPointsIndices:
|
||||||
selectedPointsIndices?.[0] > 0
|
selectedPointsIndices?.[0] > 0
|
||||||
@ -298,9 +291,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, {
|
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||||
type: app.defaultSelectionTool,
|
|
||||||
}),
|
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
|
|||||||
@ -10,8 +10,6 @@ import { distributeElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element";
|
import type { Distribution } from "@excalidraw/element";
|
||||||
@ -33,11 +31,7 @@ import type { AppClassProperties, AppState } from "../types";
|
|||||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return (
|
return (
|
||||||
getSelectedElementsByGroup(
|
selectedElements.length > 1 &&
|
||||||
selectedElements,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
appState as Readonly<AppState>,
|
|
||||||
).length > 2 &&
|
|
||||||
// TODO enable distributing frames when implemented properly
|
// TODO enable distributing frames when implemented properly
|
||||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||||
);
|
);
|
||||||
@ -55,7 +49,6 @@ const distributeSelectedElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
distribution,
|
distribution,
|
||||||
appState,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.editingLinearElement) {
|
||||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||||
try {
|
try {
|
||||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||||
|
|||||||
@ -5,11 +5,7 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element/binding";
|
||||||
import {
|
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||||
isValidPolygon,
|
|
||||||
LinearElementEditor,
|
|
||||||
newElementWith,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
@ -82,14 +78,7 @@ export const actionFinalize = register({
|
|||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.map((el) => {
|
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||||
if (el.id === element.id) {
|
|
||||||
return newElementWith(el, {
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
@ -105,9 +94,9 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.selectedLinearElement;
|
appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@ -128,21 +117,12 @@ export const actionFinalize = register({
|
|||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
? elements.map((el) => {
|
? elements.filter((el) => el.id !== element.id)
|
||||||
if (el.id === element.id) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
})
|
|
||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
selectedLinearElement: new LinearElementEditor(
|
editingLinearElement: null,
|
||||||
element,
|
|
||||||
arrayToMap(elementsMap),
|
|
||||||
false, // exit editing mode
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -174,7 +154,11 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
// pen and mouse have hover
|
// pen and mouse have hover
|
||||||
if (appState.multiElement && element.type !== "freedraw") {
|
if (
|
||||||
|
appState.multiElement &&
|
||||||
|
element.type !== "freedraw" &&
|
||||||
|
appState.lastPointerDownWith !== "touch"
|
||||||
|
) {
|
||||||
const { points, lastCommittedPoint } = element;
|
const { points, lastCommittedPoint } = element;
|
||||||
if (
|
if (
|
||||||
!lastCommittedPoint ||
|
!lastCommittedPoint ||
|
||||||
@ -188,12 +172,7 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.map((el) => {
|
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||||
if (el.id === element?.id) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
@ -261,13 +240,13 @@ export const actionFinalize = register({
|
|||||||
if (appState.activeTool.type === "eraser") {
|
if (appState.activeTool.type === "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: app.defaultSelectionTool,
|
type: "selection",
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: app.defaultSelectionTool,
|
type: "selection",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,7 +289,7 @@ export const actionFinalize = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.selectedLinearElement?.isEditing ||
|
(appState.editingLinearElement !== null ||
|
||||||
(!appState.newElement && appState.multiElement === null))) ||
|
(!appState.newElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { arrayToMap, invariant } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
toggleLinePolygonState,
|
toggleLinePolygonState,
|
||||||
@ -45,7 +46,7 @@ export const actionToggleLinearEditor = register({
|
|||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
if (
|
if (
|
||||||
!appState.selectedLinearElement?.isEditing &&
|
!appState.editingLinearElement &&
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
isLinearElement(selectedElements[0]) &&
|
isLinearElement(selectedElements[0]) &&
|
||||||
!isElbowArrow(selectedElements[0])
|
!isElbowArrow(selectedElements[0])
|
||||||
@ -60,25 +61,14 @@ export const actionToggleLinearEditor = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
invariant(selectedElement, "No selected element found");
|
const editingLinearElement =
|
||||||
invariant(
|
appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
appState.selectedLinearElement,
|
? null
|
||||||
"No selected linear element found",
|
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
||||||
);
|
|
||||||
invariant(
|
|
||||||
selectedElement.id === appState.selectedLinearElement.elementId,
|
|
||||||
"Selected element ID and linear editor elementId does not match",
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedLinearElement = {
|
|
||||||
...appState.selectedLinearElement,
|
|
||||||
isEditing: !appState.selectedLinearElement.isEditing,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedLinearElement,
|
editingLinearElement,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -88,10 +78,6 @@ export const actionToggleLinearEditor = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
if (!selectedElement) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = t(
|
const label = t(
|
||||||
selectedElement.type === "arrow"
|
selectedElement.type === "arrow"
|
||||||
? "labels.lineEditor.editArrow"
|
? "labels.lineEditor.editArrow"
|
||||||
|
|||||||
@ -137,11 +137,6 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import {
|
|
||||||
withCaretPositionPreservation,
|
|
||||||
restoreCaretPosition,
|
|
||||||
} from "../hooks/useTextEditorFocus";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
@ -326,11 +321,9 @@ export const actionChangeStrokeColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<>
|
<>
|
||||||
{appState.stylesPanelMode === "full" && (
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
|
||||||
)}
|
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||||
@ -348,7 +341,6 @@ export const actionChangeStrokeColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
compactMode={appState.stylesPanelMode === "compact"}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -406,11 +398,9 @@ export const actionChangeBackgroundColor = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<>
|
<>
|
||||||
{appState.stylesPanelMode === "full" && (
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
|
||||||
)}
|
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||||
@ -428,7 +418,6 @@ export const actionChangeBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
compactMode={appState.stylesPanelMode === "compact"}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -529,11 +518,9 @@ export const actionChangeStrokeWidth = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{appState.stylesPanelMode === "full" && (
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
|
||||||
)}
|
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
@ -588,11 +575,9 @@ export const actionChangeSloppiness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{appState.stylesPanelMode === "full" && (
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
|
||||||
)}
|
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
@ -643,11 +628,9 @@ export const actionChangeStrokeStyle = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{appState.stylesPanelMode === "full" && (
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
|
||||||
)}
|
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="strokeStyle"
|
group="strokeStyle"
|
||||||
@ -714,7 +697,7 @@ export const actionChangeFontSize = register({
|
|||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@ -773,14 +756,7 @@ export const actionChangeFontSize = register({
|
|||||||
? null
|
? null
|
||||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => {
|
onChange={(value) => updateData(value)}
|
||||||
withCaretPositionPreservation(
|
|
||||||
() => updateData(value),
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
!!appState.editingTextElement,
|
|
||||||
data?.onPreventClose,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1040,7 +1016,7 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, app, updateData, data }) => {
|
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||||
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
||||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||||
@ -1118,28 +1094,20 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{appState.stylesPanelMode === "full" && (
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
|
||||||
)}
|
|
||||||
<FontPicker
|
<FontPicker
|
||||||
isOpened={appState.openPopup === "fontFamily"}
|
isOpened={appState.openPopup === "fontFamily"}
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||||
compactMode={appState.stylesPanelMode === "compact"}
|
|
||||||
onSelect={(fontFamily) => {
|
onSelect={(fontFamily) => {
|
||||||
withCaretPositionPreservation(
|
setBatchedData({
|
||||||
() => {
|
openPopup: null,
|
||||||
setBatchedData({
|
currentHoveredFontFamily: null,
|
||||||
openPopup: null,
|
currentItemFontFamily: fontFamily,
|
||||||
currentHoveredFontFamily: null,
|
});
|
||||||
currentItemFontFamily: fontFamily,
|
|
||||||
});
|
// defensive clear so immediate close won't abuse the cached elements
|
||||||
// defensive clear so immediate close won't abuse the cached elements
|
cachedElementsRef.current.clear();
|
||||||
cachedElementsRef.current.clear();
|
|
||||||
},
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
!!appState.editingTextElement,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
onHover={(fontFamily) => {
|
onHover={(fontFamily) => {
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
@ -1196,28 +1164,25 @@ export const actionChangeFontFamily = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
...batchedData,
|
|
||||||
openPopup: "fontFamily",
|
openPopup: "fontFamily",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const fontFamilyData = {
|
// close, use the cache and clear it afterwards
|
||||||
|
const data = {
|
||||||
|
openPopup: null,
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cachedElements: new Map(cachedElementsRef.current),
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
resetAll: true,
|
resetAll: true,
|
||||||
} as ChangeFontFamilyData;
|
} as ChangeFontFamilyData;
|
||||||
|
|
||||||
setBatchedData({
|
if (isUnmounted.current) {
|
||||||
...fontFamilyData,
|
// in case the component was unmounted by the parent, trigger the update directly
|
||||||
});
|
updateData({ ...batchedData, ...data });
|
||||||
cachedElementsRef.current.clear();
|
} else {
|
||||||
|
setBatchedData(data);
|
||||||
// Refocus text editor when font picker closes if we were editing text
|
|
||||||
if (
|
|
||||||
appState.stylesPanelMode === "compact" &&
|
|
||||||
appState.editingTextElement
|
|
||||||
) {
|
|
||||||
restoreCaretPosition(null); // Just refocus without saved position
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cachedElementsRef.current.clear();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -1260,9 +1225,8 @@ export const actionChangeTextAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
@ -1311,14 +1275,7 @@ export const actionChangeTextAlign = register({
|
|||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemTextAlign,
|
hasSelection ? null : appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => {
|
onChange={(value) => updateData(value)}
|
||||||
withCaretPositionPreservation(
|
|
||||||
() => updateData(value),
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
!!appState.editingTextElement,
|
|
||||||
data?.onPreventClose,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1360,7 +1317,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@ -1410,14 +1367,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
) !== null,
|
) !== null,
|
||||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||||
)}
|
)}
|
||||||
onChange={(value) => {
|
onChange={(value) => updateData(value)}
|
||||||
withCaretPositionPreservation(
|
|
||||||
() => updateData(value),
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
!!appState.editingTextElement,
|
|
||||||
data?.onPreventClose,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1666,25 +1616,6 @@ export const actionChangeArrowhead = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeArrowProperties = register({
|
|
||||||
name: "changeArrowProperties",
|
|
||||||
label: "Change arrow properties",
|
|
||||||
trackEvent: false,
|
|
||||||
perform: (elements, appState, value, app) => {
|
|
||||||
// This action doesn't perform any changes directly
|
|
||||||
// It's just a container for the arrow type and arrowhead actions
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
|
||||||
return (
|
|
||||||
<div className="selected-shape-actions">
|
|
||||||
{renderAction("changeArrowType")}
|
|
||||||
{renderAction("changeArrowhead")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionChangeArrowType = register({
|
export const actionChangeArrowType = register({
|
||||||
name: "changeArrowType",
|
name: "changeArrowType",
|
||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const actionSelectAll = register({
|
|||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,6 @@ export {
|
|||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
actionChangeArrowProperties,
|
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -69,7 +69,6 @@ export type ActionName =
|
|||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
| "changeArrowProperties"
|
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
newElement: null,
|
newElement: null,
|
||||||
editingTextElement: null,
|
editingTextElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
|
editingLinearElement: null,
|
||||||
activeTool: {
|
activeTool: {
|
||||||
type: "selection",
|
type: "selection",
|
||||||
customType: null,
|
customType: null,
|
||||||
@ -123,7 +124,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
searchMatches: null,
|
searchMatches: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
stylesPanelMode: "full",
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -175,6 +175,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
newElement: { browser: false, export: false, server: false },
|
newElement: { browser: false, export: false, server: false },
|
||||||
editingTextElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
penMode: { browser: true, export: false, server: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
penDetected: { browser: true, export: false, server: false },
|
penDetected: { browser: true, export: false, server: false },
|
||||||
@ -248,7 +249,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||||
activeLockedId: { browser: false, export: false, server: false },
|
activeLockedId: { browser: false, export: false, server: false },
|
||||||
stylesPanelMode: { browser: true, export: false, server: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
createPasteEvent,
|
createPasteEvent,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
parseDataTransferEvent,
|
|
||||||
serializeAsClipboardJSON,
|
serializeAsClipboardJSON,
|
||||||
} from "./clipboard";
|
} from "./clipboard";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@ -14,9 +13,7 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "123";
|
text = "123";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@ -24,9 +21,7 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@ -34,9 +29,7 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = JSON.stringify({ val: 42 });
|
text = JSON.stringify({ val: 42 });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
@ -46,13 +39,11 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/plain": json,
|
||||||
"text/plain": json,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
});
|
});
|
||||||
@ -65,25 +56,21 @@ describe("parseClipboard()", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": json,
|
||||||
"text/html": json,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": `<div> ${json}</div>`,
|
||||||
"text/html": `<div> ${json}</div>`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -93,13 +80,11 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": `<img src="https://example.com/image.png" />`,
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -109,13 +94,11 @@ describe("parseClipboard()", () => {
|
|||||||
]);
|
]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -131,13 +114,11 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -160,16 +141,14 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/plain": `a b
|
||||||
"text/plain": `a b
|
1 2
|
||||||
1 2
|
4 5
|
||||||
4 5
|
7 10`,
|
||||||
7 10`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -178,16 +157,14 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": `a b
|
||||||
"text/html": `a b
|
1 2
|
||||||
1 2
|
4 5
|
||||||
4 5
|
7 10`,
|
||||||
7 10`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -196,21 +173,19 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
await parseDataTransferEvent(
|
createPasteEvent({
|
||||||
createPasteEvent({
|
types: {
|
||||||
types: {
|
"text/html": `<html>
|
||||||
"text/html": `<html>
|
<body>
|
||||||
<body>
|
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
</body>
|
||||||
</body>
|
</html>`,
|
||||||
</html>`,
|
"text/plain": `a b
|
||||||
"text/plain": `a b
|
1 2
|
||||||
1 2
|
4 5
|
||||||
4 5
|
7 10`,
|
||||||
7 10`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
isMemberOf,
|
isMemberOf,
|
||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
EVENT,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
@ -17,26 +16,15 @@ import {
|
|||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
|
||||||
|
|
||||||
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ExcalidrawError } from "./errors";
|
import { ExcalidrawError } from "./errors";
|
||||||
import {
|
import { createFile, isSupportedImageFileType } from "./data/blob";
|
||||||
createFile,
|
|
||||||
getFileHandle,
|
|
||||||
isSupportedImageFileType,
|
|
||||||
normalizeFile,
|
|
||||||
} from "./data/blob";
|
|
||||||
|
|
||||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
|
|
||||||
import type { FileSystemHandle } from "./data/filesystem";
|
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
@ -104,7 +92,7 @@ export const createPasteEvent = ({
|
|||||||
console.warn("createPasteEvent: no types or files provided");
|
console.warn("createPasteEvent: no types or files provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new ClipboardEvent(EVENT.PASTE, {
|
const event = new ClipboardEvent("paste", {
|
||||||
clipboardData: new DataTransfer(),
|
clipboardData: new DataTransfer(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,11 +101,10 @@ export const createPasteEvent = ({
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
files = files || [];
|
files = files || [];
|
||||||
files.push(value);
|
files.push(value);
|
||||||
event.clipboardData?.items.add(value);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.items.add(value, type);
|
event.clipboardData?.setData(type, value);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||||
}
|
}
|
||||||
@ -242,10 +229,14 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeParseHTMLDataItem = (
|
const maybeParseHTMLPaste = (
|
||||||
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
event: ClipboardEvent,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = dataItem.value;
|
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
@ -341,21 +332,18 @@ export const readSystemClipboard = async () => {
|
|||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEventTextData = async (
|
||||||
dataList: ParsedDataTranferList,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||||
|
|
||||||
const mixedContent =
|
|
||||||
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
|
||||||
|
|
||||||
if (mixedContent) {
|
if (mixedContent) {
|
||||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
dataList.getData(MIME_TYPES.text) ??
|
event.clipboardData?.getData(MIME_TYPES.text) ||
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@ -366,155 +354,23 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
||||||
type: "text",
|
|
||||||
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
return { type: "text", value: (text || "").trim() };
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: "" };
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type AllowedParsedDataTransferItem =
|
|
||||||
| {
|
|
||||||
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
|
||||||
kind: "file";
|
|
||||||
file: File;
|
|
||||||
fileHandle: FileSystemHandle | null;
|
|
||||||
}
|
|
||||||
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
|
||||||
|
|
||||||
type ParsedDataTransferItem =
|
|
||||||
| {
|
|
||||||
type: string;
|
|
||||||
kind: "file";
|
|
||||||
file: File;
|
|
||||||
fileHandle: FileSystemHandle | null;
|
|
||||||
}
|
|
||||||
| { type: string; kind: "string"; value: string };
|
|
||||||
|
|
||||||
type ParsedDataTransferItemType<
|
|
||||||
T extends AllowedParsedDataTransferItem["type"],
|
|
||||||
> = AllowedParsedDataTransferItem & { type: T };
|
|
||||||
|
|
||||||
export type ParsedDataTransferFile = Extract<
|
|
||||||
AllowedParsedDataTransferItem,
|
|
||||||
{ kind: "file" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
|
||||||
/**
|
|
||||||
* Only allows filtering by known `string` data types, since `file`
|
|
||||||
* types can have multiple items of the same type (e.g. multiple image files)
|
|
||||||
* unlike `string` data transfer items.
|
|
||||||
*/
|
|
||||||
findByType: typeof findDataTransferItemType;
|
|
||||||
/**
|
|
||||||
* Only allows filtering by known `string` data types, since `file`
|
|
||||||
* types can have multiple items of the same type (e.g. multiple image files)
|
|
||||||
* unlike `string` data transfer items.
|
|
||||||
*/
|
|
||||||
getData: typeof getDataTransferItemData;
|
|
||||||
getFiles: typeof getDataTransferFiles;
|
|
||||||
};
|
|
||||||
|
|
||||||
const findDataTransferItemType = function <
|
|
||||||
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
|
||||||
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
|
||||||
return (
|
|
||||||
this.find(
|
|
||||||
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const getDataTransferItemData = function <
|
|
||||||
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
|
||||||
>(
|
|
||||||
this: ParsedDataTranferList,
|
|
||||||
type: T,
|
|
||||||
):
|
|
||||||
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
|
||||||
| null {
|
|
||||||
const item = this.find(
|
|
||||||
(
|
|
||||||
item,
|
|
||||||
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
|
||||||
item.type === type,
|
|
||||||
);
|
|
||||||
|
|
||||||
return item?.value ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDataTransferFiles = function (
|
|
||||||
this: ParsedDataTranferList,
|
|
||||||
): ParsedDataTransferFile[] {
|
|
||||||
return this.filter(
|
|
||||||
(item): item is ParsedDataTransferFile => item.kind === "file",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseDataTransferEvent = async (
|
|
||||||
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
|
||||||
): Promise<ParsedDataTranferList> => {
|
|
||||||
let items: DataTransferItemList | undefined = undefined;
|
|
||||||
|
|
||||||
if (isClipboardEvent(event)) {
|
|
||||||
items = event.clipboardData?.items;
|
|
||||||
} else {
|
|
||||||
const dragEvent = event;
|
|
||||||
items = dragEvent.dataTransfer?.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataItems = (
|
|
||||||
await Promise.all(
|
|
||||||
Array.from(items || []).map(
|
|
||||||
async (item): Promise<ParsedDataTransferItem | null> => {
|
|
||||||
if (item.kind === "file") {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) {
|
|
||||||
const fileHandle = await getFileHandle(item);
|
|
||||||
return {
|
|
||||||
type: file.type,
|
|
||||||
kind: "file",
|
|
||||||
file: await normalizeFile(file),
|
|
||||||
fileHandle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (item.kind === "string") {
|
|
||||||
const { type } = item;
|
|
||||||
let value: string;
|
|
||||||
if ("clipboardData" in event && event.clipboardData) {
|
|
||||||
value = event.clipboardData?.getData(type);
|
|
||||||
} else {
|
|
||||||
value = await new Promise<string>((resolve) => {
|
|
||||||
item.getAsString((str) => resolve(str));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { type, kind: "string", value };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).filter((data): data is ParsedDataTransferItem => data != null);
|
|
||||||
|
|
||||||
return Object.assign(dataItems, {
|
|
||||||
findByType: findDataTransferItemType,
|
|
||||||
getData: getDataTransferItemData,
|
|
||||||
getFiles: getDataTransferFiles,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
dataList: ParsedDataTranferList,
|
event: ClipboardEvent,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
dataList,
|
event,
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -663,14 +519,3 @@ const copyTextViaExecCommand = (text: string | null) => {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isClipboardEvent = (
|
|
||||||
event: React.SyntheticEvent | Event,
|
|
||||||
): event is ClipboardEvent => {
|
|
||||||
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
|
||||||
return (
|
|
||||||
event.type === EVENT.PASTE ||
|
|
||||||
event.type === EVENT.COPY ||
|
|
||||||
event.type === EVENT.CUT
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -91,120 +91,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-shape-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
max-height: calc(100vh - 200px);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.5rem;
|
|
||||||
|
|
||||||
.compact-action-item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
|
|
||||||
--default-button-size: 2rem;
|
|
||||||
|
|
||||||
.compact-action-button {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--button-hover-bg, var(--island-bg-color));
|
|
||||||
border-color: var(
|
|
||||||
--button-hover-border,
|
|
||||||
var(--button-border, var(--default-border-color))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: var(--button-active-bg, var(--island-bg-color));
|
|
||||||
border-color: var(--button-active-border, var(--color-primary-darkest));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-popover-content {
|
|
||||||
.popover-section {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-section-title {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonList {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-shape-actions-island {
|
|
||||||
width: fit-content;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-popover-content {
|
|
||||||
.popover-section {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-section-title {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonList {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.shape-actions-theme-scope {
|
|
||||||
--button-border: transparent;
|
|
||||||
--button-bg: var(--color-surface-mid);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.theme--dark .shape-actions-theme-scope {
|
|
||||||
--button-hover-bg: #363541;
|
|
||||||
--button-bg: var(--color-surface-high);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
@ -20,7 +19,6 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isArrowElement,
|
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
||||||
@ -48,20 +46,15 @@ import {
|
|||||||
hasStrokeWidth,
|
hasStrokeWidth,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { getFormValue } from "../actions/actionProperties";
|
import { SHAPES } from "./shapes";
|
||||||
|
|
||||||
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
|
||||||
|
|
||||||
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[],
|
||||||
@ -165,7 +140,7 @@ export const SelectedShapeActions = ({
|
|||||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||||
|
|
||||||
const showLineEditorAction =
|
const showLineEditorAction =
|
||||||
!appState.selectedLinearElement?.isEditing &&
|
!appState.editingLinearElement &&
|
||||||
targetElements.length === 1 &&
|
targetElements.length === 1 &&
|
||||||
isLinearElement(targetElements[0]) &&
|
isLinearElement(targetElements[0]) &&
|
||||||
!isElbowArrow(targetElements[0]);
|
!isElbowArrow(targetElements[0]);
|
||||||
@ -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,
|
||||||
@ -751,8 +295,7 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
const frameToolSelected = activeTool.type === "frame";
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
const laserToolSelected = activeTool.type === "laser";
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
const lassoToolSelected =
|
const lassoToolSelected = activeTool.type === "lasso";
|
||||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
|
||||||
|
|
||||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
@ -760,68 +303,63 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{getToolbarTools(app).map(
|
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
({ value, icon, key, numericKey, fillable }, index) => {
|
if (
|
||||||
if (
|
UIOptions.tools?.[
|
||||||
UIOptions.tools?.[
|
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||||
value as Extract<
|
] === false
|
||||||
typeof value,
|
) {
|
||||||
keyof AppProps["UIOptions"]["tools"]
|
return null;
|
||||||
>
|
}
|
||||||
] === false
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter =
|
const letter =
|
||||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = letter
|
const shortcut = letter
|
||||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||||
: `${numericKey}`;
|
: `${numericKey}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className={clsx("Shape", { fillable })}
|
className={clsx("Shape", { fillable })}
|
||||||
key={value}
|
key={value}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={activeTool.type === value}
|
checked={activeTool.type === value}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
keyBindingLabel={numericKey || letter}
|
keyBindingLabel={numericKey || letter}
|
||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={`toolbar-${value}`}
|
data-testid={`toolbar-${value}`}
|
||||||
onPointerDown={({ pointerType }) => {
|
onPointerDown={({ pointerType }) => {
|
||||||
if (!appState.penDetected && pointerType === "pen") {
|
if (!appState.penDetected && pointerType === "pen") {
|
||||||
app.togglePenMode(true);
|
app.togglePenMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "selection") {
|
if (value === "selection") {
|
||||||
if (appState.activeTool.type === "selection") {
|
if (appState.activeTool.type === "selection") {
|
||||||
app.setActiveTool({ type: "lasso" });
|
app.setActiveTool({ type: "lasso" });
|
||||||
} else {
|
|
||||||
app.setActiveTool({ type: "selection" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={({ pointerType }) => {
|
|
||||||
if (appState.activeTool.type !== value) {
|
|
||||||
trackEvent("toolbar", value, "ui");
|
|
||||||
}
|
|
||||||
if (value === "image") {
|
|
||||||
app.setActiveTool({
|
|
||||||
type: value,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
app.setActiveTool({ type: value });
|
app.setActiveTool({ type: "selection" });
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
);
|
onChange={({ pointerType }) => {
|
||||||
},
|
if (appState.activeTool.type !== value) {
|
||||||
)}
|
trackEvent("toolbar", value, "ui");
|
||||||
|
}
|
||||||
|
if (value === "image") {
|
||||||
|
app.setActiveTool({
|
||||||
|
type: value,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className="App-toolbar__divider" />
|
<div className="App-toolbar__divider" />
|
||||||
|
|
||||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||||
@ -880,16 +418,14 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.laser")}
|
{t("toolBar.laser")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{app.defaultSelectionTool !== "lasso" && (
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.Item
|
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
icon={LassoIcon}
|
||||||
icon={LassoIcon}
|
data-testid="toolbar-lasso"
|
||||||
data-testid="toolbar-lasso"
|
selected={lassoToolSelected}
|
||||||
selected={lassoToolSelected}
|
>
|
||||||
>
|
{t("toolBar.lasso")}
|
||||||
{t("toolBar.lasso")}
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Item>
|
|
||||||
)}
|
|
||||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||||
Generate
|
Generate
|
||||||
</div>
|
</div>
|
||||||
@ -969,3 +505,15 @@ export const ExitZenModeAction = ({
|
|||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const FinalizeAction = ({
|
||||||
|
renderAction,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
renderAction: ActionManager["renderAction"];
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={`finalize-button ${className}`}>
|
||||||
|
{renderAction("finalize", { size: "small" })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -108,7 +108,6 @@ $verticalBreakpoint: 861px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,8 +59,6 @@ import { useStableCallback } from "../../hooks/useStableCallback";
|
|||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { useStable } from "../../hooks/useStable";
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
import { Ellipsify } from "../Ellipsify";
|
|
||||||
|
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
|
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
@ -966,7 +964,7 @@ const CommandItem = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Ellipsify>{command.label}</Ellipsify>
|
{command.label}
|
||||||
</div>
|
</div>
|
||||||
{showShortcut && command.shortcut && (
|
{showShortcut && command.shortcut && (
|
||||||
<CommandShortcutHint shortcut={command.shortcut} />
|
<CommandShortcutHint shortcut={command.shortcut} />
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
export const Ellipsify = ({
|
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...rest}
|
|
||||||
style={{
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
overflow: "hidden",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
...rest.style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -11,10 +11,5 @@
|
|||||||
2rem + 4 * var(--default-button-size)
|
2rem + 4 * var(--default-button-size)
|
||||||
); // 4 gaps + 4 buttons
|
); // 4 gaps + 4 buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
&--compact {
|
|
||||||
display: block;
|
|
||||||
grid-template-columns: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import clsx from "clsx";
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY } from "@excalidraw/common";
|
||||||
@ -59,7 +58,6 @@ interface FontPickerProps {
|
|||||||
onHover: (fontFamily: FontFamilyValues) => void;
|
onHover: (fontFamily: FontFamilyValues) => void;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
onPopupChange: (open: boolean) => void;
|
onPopupChange: (open: boolean) => void;
|
||||||
compactMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPicker = React.memo(
|
export const FontPicker = React.memo(
|
||||||
@ -71,7 +69,6 @@ export const FontPicker = React.memo(
|
|||||||
onHover,
|
onHover,
|
||||||
onLeave,
|
onLeave,
|
||||||
onPopupChange,
|
onPopupChange,
|
||||||
compactMode = false,
|
|
||||||
}: FontPickerProps) => {
|
}: FontPickerProps) => {
|
||||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||||
const onSelectCallback = useCallback(
|
const onSelectCallback = useCallback(
|
||||||
@ -84,29 +81,18 @@ export const FontPicker = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||||
role="dialog"
|
<div className="buttonList">
|
||||||
aria-modal="true"
|
<RadioSelection<FontFamilyValues | false>
|
||||||
className={clsx("FontPicker__container", {
|
type="button"
|
||||||
"FontPicker__container--compact": compactMode,
|
options={defaultFonts}
|
||||||
})}
|
value={selectedFontFamily}
|
||||||
>
|
onClick={onSelectCallback}
|
||||||
{!compactMode && (
|
|
||||||
<div className="buttonList">
|
|
||||||
<RadioSelection<FontFamilyValues | false>
|
|
||||||
type="button"
|
|
||||||
options={defaultFonts}
|
|
||||||
value={selectedFontFamily}
|
|
||||||
onClick={onSelectCallback}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!compactMode && <ButtonSeparator />}
|
|
||||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
|
||||||
<FontPickerTrigger
|
|
||||||
selectedFontFamily={selectedFontFamily}
|
|
||||||
isOpened={isOpened}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<ButtonSeparator />
|
||||||
|
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||||
|
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||||
{isOpened && (
|
{isOpened && (
|
||||||
<FontPickerList
|
<FontPickerList
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -115,7 +115,7 @@ const getHints = ({
|
|||||||
appState.selectionElement &&
|
appState.selectionElement &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingTextElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.selectedLinearElement?.isEditing
|
!appState.editingLinearElement
|
||||||
) {
|
) {
|
||||||
return [t("hints.deepBoxSelect")];
|
return [t("hints.deepBoxSelect")];
|
||||||
}
|
}
|
||||||
@ -130,8 +130,8 @@ const getHints = ({
|
|||||||
|
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
if (isLinearElement(selectedElements[0])) {
|
if (isLinearElement(selectedElements[0])) {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.editingLinearElement) {
|
||||||
return appState.selectedLinearElement.selectedPointsIndices
|
return appState.editingLinearElement.selectedPointsIndices
|
||||||
? t("hints.lineEditor_pointSelected")
|
? t("hints.lineEditor_pointSelected")
|
||||||
: t("hints.lineEditor_nothingSelected");
|
: t("hints.lineEditor_nothingSelected");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
|||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@ -24,10 +24,6 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
|
||||||
&--compact {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
MQ_MIN_WIDTH_DESKTOP,
|
|
||||||
TOOL_TYPE,
|
TOOL_TYPE,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
capitalizeString,
|
capitalizeString,
|
||||||
@ -29,11 +28,7 @@ import { useAtom, useAtomValue } from "../editor-jotai";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
|
|
||||||
import {
|
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
SelectedShapeActions,
|
|
||||||
ShapesSwitcher,
|
|
||||||
CompactShapeActions,
|
|
||||||
} from "./Actions";
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@ -162,25 +157,6 @@ const LayerUI = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
const spacing =
|
|
||||||
appState.stylesPanelMode === "compact"
|
|
||||||
? {
|
|
||||||
menuTopGap: 4,
|
|
||||||
toolbarColGap: 4,
|
|
||||||
toolbarRowGap: 1,
|
|
||||||
toolbarInnerRowGap: 0.5,
|
|
||||||
islandPadding: 1,
|
|
||||||
collabMarginLeft: 8,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
menuTopGap: 6,
|
|
||||||
toolbarColGap: 4,
|
|
||||||
toolbarRowGap: 1,
|
|
||||||
toolbarInnerRowGap: 1,
|
|
||||||
islandPadding: 1,
|
|
||||||
collabMarginLeft: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
@ -233,55 +209,31 @@ const LayerUI = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderSelectedShapeActions = () => {
|
const renderSelectedShapeActions = () => (
|
||||||
const isCompactMode = appState.stylesPanelMode === "compact";
|
<Section
|
||||||
|
heading="selectedShapeActions"
|
||||||
return (
|
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||||
<Section
|
"transition-left": appState.zenModeEnabled,
|
||||||
heading="selectedShapeActions"
|
})}
|
||||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
>
|
||||||
"transition-left": appState.zenModeEnabled,
|
<Island
|
||||||
})}
|
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||||
|
padding={2}
|
||||||
|
style={{
|
||||||
|
// we want to make sure this doesn't overflow so subtracting the
|
||||||
|
// approximate height of hamburgerMenu + footer
|
||||||
|
maxHeight: `${appState.height - 166}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isCompactMode ? (
|
<SelectedShapeActions
|
||||||
<Island
|
appState={appState}
|
||||||
className={clsx("compact-shape-actions-island")}
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
padding={0}
|
renderAction={actionManager.renderAction}
|
||||||
style={{
|
app={app}
|
||||||
// we want to make sure this doesn't overflow so subtracting the
|
/>
|
||||||
// approximate height of hamburgerMenu + footer
|
</Island>
|
||||||
maxHeight: `${appState.height - 166}px`,
|
</Section>
|
||||||
}}
|
);
|
||||||
>
|
|
||||||
<CompactShapeActions
|
|
||||||
appState={appState}
|
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
app={app}
|
|
||||||
setAppState={setAppState}
|
|
||||||
/>
|
|
||||||
</Island>
|
|
||||||
) : (
|
|
||||||
<Island
|
|
||||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
|
||||||
padding={2}
|
|
||||||
style={{
|
|
||||||
// we want to make sure this doesn't overflow so subtracting the
|
|
||||||
// approximate height of hamburgerMenu + footer
|
|
||||||
maxHeight: `${appState.height - 166}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectedShapeActions
|
|
||||||
appState={appState}
|
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
</Island>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
@ -298,19 +250,9 @@ const LayerUI = ({
|
|||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col
|
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||||
gap={spacing.menuTopGap}
|
|
||||||
className={clsx("App-menu_top__left")}
|
|
||||||
>
|
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
<div
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
className={clsx("selected-shape-actions-container", {
|
|
||||||
"selected-shape-actions-container--compact":
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
|
||||||
</div>
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||||
@ -320,19 +262,17 @@ const LayerUI = ({
|
|||||||
{renderWelcomeScreen && (
|
{renderWelcomeScreen && (
|
||||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||||
)}
|
)}
|
||||||
<Stack.Col gap={spacing.toolbarColGap} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row
|
<Stack.Row
|
||||||
gap={spacing.toolbarRowGap}
|
gap={1}
|
||||||
className={clsx("App-toolbar-container", {
|
className={clsx("App-toolbar-container", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island
|
<Island
|
||||||
padding={spacing.islandPadding}
|
padding={1}
|
||||||
className={clsx("App-toolbar", {
|
className={clsx("App-toolbar", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
"App-toolbar--compact":
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
@ -342,7 +282,7 @@ const LayerUI = ({
|
|||||||
app={app}
|
app={app}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={spacing.toolbarInnerRowGap}>
|
<Stack.Row gap={1}>
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
zenModeEnabled={appState.zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
@ -376,7 +316,7 @@ const LayerUI = ({
|
|||||||
{isCollaborating && (
|
{isCollaborating && (
|
||||||
<Island
|
<Island
|
||||||
style={{
|
style={{
|
||||||
marginLeft: spacing.collabMarginLeft,
|
marginLeft: 8,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
height: "fit-content",
|
height: "fit-content",
|
||||||
}}
|
}}
|
||||||
@ -404,8 +344,6 @@ const LayerUI = ({
|
|||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right": appState.zenModeEnabled,
|
"transition-right": appState.zenModeEnabled,
|
||||||
"layer-ui__wrapper__top-right--compact":
|
|
||||||
appState.stylesPanelMode === "compact",
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -480,9 +418,7 @@ const LayerUI = ({
|
|||||||
}}
|
}}
|
||||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||||
>
|
>
|
||||||
{appState.stylesPanelMode === "full" &&
|
{t("toolBar.library")}
|
||||||
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
|
|
||||||
t("toolBar.library")}
|
|
||||||
</DefaultSidebar.Trigger>
|
</DefaultSidebar.Trigger>
|
||||||
<DefaultOverwriteConfirmDialog />
|
<DefaultOverwriteConfirmDialog />
|
||||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -192,6 +192,7 @@ const getRelevantAppStateProps = (
|
|||||||
viewModeEnabled: appState.viewModeEnabled,
|
viewModeEnabled: appState.viewModeEnabled,
|
||||||
openDialog: appState.openDialog,
|
openDialog: appState.openDialog,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
|
editingLinearElement: appState.editingLinearElement,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
frameToHighlight: appState.frameToHighlight,
|
frameToHighlight: appState.frameToHighlight,
|
||||||
offsetLeft: appState.offsetLeft,
|
offsetLeft: appState.offsetLeft,
|
||||||
|
|||||||
@ -34,13 +34,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
|||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
props.canvas.style.width = `${props.appState.width}px`;
|
|
||||||
props.canvas.style.height = `${props.appState.height}px`;
|
|
||||||
props.canvas.width = props.appState.width * props.scale;
|
|
||||||
props.canvas.height = props.appState.height * props.scale;
|
|
||||||
}, [props.appState.height, props.appState.width, props.canvas, props.scale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wrapper = wrapperRef.current;
|
const wrapper = wrapperRef.current;
|
||||||
if (!wrapper) {
|
if (!wrapper) {
|
||||||
@ -56,6 +49,26 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
|||||||
canvas.classList.add("excalidraw__canvas", "static");
|
canvas.classList.add("excalidraw__canvas", "static");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const widthString = `${props.appState.width}px`;
|
||||||
|
const heightString = `${props.appState.height}px`;
|
||||||
|
if (canvas.style.width !== widthString) {
|
||||||
|
canvas.style.width = widthString;
|
||||||
|
}
|
||||||
|
if (canvas.style.height !== heightString) {
|
||||||
|
canvas.style.height = heightString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaledWidth = props.appState.width * props.scale;
|
||||||
|
const scaledHeight = props.appState.height * props.scale;
|
||||||
|
// setting width/height resets the canvas even if dimensions not changed,
|
||||||
|
// which would cause flicker when we skip frame (due to throttling)
|
||||||
|
if (canvas.width !== scaledWidth) {
|
||||||
|
canvas.width = scaledWidth;
|
||||||
|
}
|
||||||
|
if (canvas.height !== scaledHeight) {
|
||||||
|
canvas.height = scaledHeight;
|
||||||
|
}
|
||||||
|
|
||||||
renderStaticScene(
|
renderStaticScene(
|
||||||
{
|
{
|
||||||
canvas,
|
canvas,
|
||||||
|
|||||||
@ -19,8 +19,6 @@
|
|||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&.zen-mode {
|
&.zen-mode {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@ -102,7 +100,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
flex: 1 0 auto;
|
|
||||||
|
|
||||||
@media screen and (min-width: 1921px) {
|
@media screen and (min-width: 1921px) {
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
import { Ellipsify } from "../Ellipsify";
|
|
||||||
|
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
const MenuItemContent = ({
|
const MenuItemContent = ({
|
||||||
@ -20,7 +18,7 @@ const MenuItemContent = ({
|
|||||||
<>
|
<>
|
||||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||||
<div style={textStyle} className="dropdown-menu-item__text">
|
<div style={textStyle} className="dropdown-menu-item__text">
|
||||||
<Ellipsify>{children}</Ellipsify>
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{shortcut && !device.editor.isMobile && (
|
{shortcut && !device.editor.isMobile && (
|
||||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||||
|
|||||||
@ -2,7 +2,13 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
import { actionShortcuts } from "../../actions";
|
import { actionShortcuts } from "../../actions";
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
|
import {
|
||||||
|
ExitZenModeAction,
|
||||||
|
FinalizeAction,
|
||||||
|
UndoRedoActions,
|
||||||
|
ZoomActions,
|
||||||
|
} from "../Actions";
|
||||||
|
import { useDevice } from "../App";
|
||||||
import { HelpButton } from "../HelpButton";
|
import { HelpButton } from "../HelpButton";
|
||||||
import { Section } from "../Section";
|
import { Section } from "../Section";
|
||||||
import Stack from "../Stack";
|
import Stack from "../Stack";
|
||||||
@ -23,6 +29,10 @@ const Footer = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||||
|
|
||||||
|
const device = useDevice();
|
||||||
|
const showFinalize =
|
||||||
|
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
role="contentinfo"
|
role="contentinfo"
|
||||||
@ -50,6 +60,15 @@ const Footer = ({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showFinalize && (
|
||||||
|
<FinalizeAction
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
className={clsx("zen-mode-transition", {
|
||||||
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
|
appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -13,8 +13,6 @@ import {
|
|||||||
EraserIcon,
|
EraserIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
|
||||||
import type { AppClassProperties } from "../types";
|
|
||||||
|
|
||||||
export const SHAPES = [
|
export const SHAPES = [
|
||||||
{
|
{
|
||||||
icon: SelectionIcon,
|
icon: SelectionIcon,
|
||||||
@ -88,23 +86,8 @@ export const SHAPES = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const getToolbarTools = (app: AppClassProperties) => {
|
export const findShapeByKey = (key: string) => {
|
||||||
return app.defaultSelectionTool === "lasso"
|
const shape = SHAPES.find((shape, index) => {
|
||||||
? ([
|
|
||||||
{
|
|
||||||
value: "lasso",
|
|
||||||
icon: SelectionIcon,
|
|
||||||
key: KEYS.V,
|
|
||||||
numericKey: KEYS["1"],
|
|
||||||
fillable: true,
|
|
||||||
},
|
|
||||||
...SHAPES.slice(1),
|
|
||||||
] as const)
|
|
||||||
: SHAPES;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
|
||||||
const shape = getToolbarTools(app).find((shape, index) => {
|
|
||||||
return (
|
return (
|
||||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||||
(shape.key &&
|
(shape.key &&
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 "";
|
||||||
};
|
};
|
||||||
@ -172,11 +170,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
},
|
},
|
||||||
localAppState,
|
localAppState,
|
||||||
localElements,
|
localElements,
|
||||||
{
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else if (isValidLibrary(data)) {
|
} else if (isValidLibrary(data)) {
|
||||||
@ -391,18 +385,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) {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
|||||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||||
MakeBrand<"RemoteExcalidrawElement">;
|
MakeBrand<"RemoteExcalidrawElement">;
|
||||||
|
|
||||||
export const shouldDiscardRemoteElement = (
|
const shouldDiscardRemoteElement = (
|
||||||
localAppState: AppState,
|
localAppState: AppState,
|
||||||
local: OrderedExcalidrawElement | undefined,
|
local: OrderedExcalidrawElement | undefined,
|
||||||
remote: RemoteExcalidrawElement,
|
remote: RemoteExcalidrawElement,
|
||||||
@ -30,7 +30,7 @@ export const shouldDiscardRemoteElement = (
|
|||||||
// local element is being edited
|
// local element is being edited
|
||||||
(local.id === localAppState.editingTextElement?.id ||
|
(local.id === localAppState.editingTextElement?.id ||
|
||||||
local.id === localAppState.resizingElement?.id ||
|
local.id === localAppState.resizingElement?.id ||
|
||||||
local.id === localAppState.newElement?.id ||
|
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||||
// local element is newer
|
// local element is newer
|
||||||
local.version > remote.version ||
|
local.version > remote.version ||
|
||||||
// resolve conflicting edits deterministically by taking the one with
|
// resolve conflicting edits deterministically by taking the one with
|
||||||
|
|||||||
@ -241,9 +241,8 @@ const restoreElementWithProperties = <
|
|||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
opts?: { deleteInvisibleElements?: boolean },
|
|
||||||
): typeof element | null => {
|
): typeof element | null => {
|
||||||
element = { ...element };
|
element = { ...element };
|
||||||
|
|
||||||
@ -291,8 +290,7 @@ export const restoreElement = (
|
|||||||
|
|
||||||
// if empty text, mark as deleted. We keep in array
|
// if empty text, mark as deleted. We keep in array
|
||||||
// for data integrity purposes (collab etc.)
|
// for data integrity purposes (collab etc.)
|
||||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
if (!text && !element.isDeleted) {
|
||||||
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
|
|
||||||
element = { ...element, originalText: text, isDeleted: true };
|
element = { ...element, originalText: text, isDeleted: true };
|
||||||
element = bumpVersion(element);
|
element = bumpVersion(element);
|
||||||
}
|
}
|
||||||
@ -387,10 +385,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,
|
||||||
})
|
})
|
||||||
@ -528,13 +523,7 @@ export const restoreElements = (
|
|||||||
elements: ImportedDataState["elements"],
|
elements: ImportedDataState["elements"],
|
||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
opts?:
|
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||||
| {
|
|
||||||
refreshDimensions?: boolean;
|
|
||||||
repairBindings?: boolean;
|
|
||||||
deleteInvisibleElements?: boolean;
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
): OrderedExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
// used to detect duplicate top-level element ids
|
// used to detect duplicate top-level element ids
|
||||||
const existingIds = new Set<string>();
|
const existingIds = new Set<string>();
|
||||||
@ -543,38 +532,24 @@ export const restoreElements = (
|
|||||||
(elements || []).reduce((elements, element) => {
|
(elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type === "selection") {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
return elements;
|
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||||
|
if (migratedElement) {
|
||||||
|
const localElement = localElementsMap?.get(element.id);
|
||||||
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
|
migratedElement = bumpVersion(
|
||||||
|
migratedElement,
|
||||||
|
localElement.version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existingIds.has(migratedElement.id)) {
|
||||||
|
migratedElement = { ...migratedElement, id: randomId() };
|
||||||
|
}
|
||||||
|
existingIds.add(migratedElement.id);
|
||||||
|
|
||||||
|
elements.push(migratedElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
|
|
||||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
|
||||||
});
|
|
||||||
if (migratedElement) {
|
|
||||||
const localElement = localElementsMap?.get(element.id);
|
|
||||||
|
|
||||||
const shouldMarkAsDeleted =
|
|
||||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
|
||||||
|
|
||||||
if (
|
|
||||||
shouldMarkAsDeleted ||
|
|
||||||
(localElement && localElement.version > migratedElement.version)
|
|
||||||
) {
|
|
||||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldMarkAsDeleted) {
|
|
||||||
migratedElement = { ...migratedElement, isDeleted: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingIds.has(migratedElement.id)) {
|
|
||||||
migratedElement = { ...migratedElement, id: randomId() };
|
|
||||||
}
|
|
||||||
existingIds.add(migratedElement.id);
|
|
||||||
|
|
||||||
elements.push(migratedElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
}, [] as ExcalidrawElement[]),
|
}, [] as ExcalidrawElement[]),
|
||||||
);
|
);
|
||||||
@ -815,11 +790,7 @@ export const restore = (
|
|||||||
*/
|
*/
|
||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null | undefined,
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
elementsConfig?: {
|
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||||
refreshDimensions?: boolean;
|
|
||||||
repairBindings?: boolean;
|
|
||||||
deleteInvisibleElements?: boolean;
|
|
||||||
},
|
|
||||||
): RestoredDataState => {
|
): RestoredDataState => {
|
||||||
return {
|
return {
|
||||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -175,7 +175,7 @@ export class History {
|
|||||||
let nextAppState = appState;
|
let nextAppState = appState;
|
||||||
let containsVisibleChange = false;
|
let containsVisibleChange = false;
|
||||||
|
|
||||||
// iterate through the history entries in case they result in no visible changes
|
// iterate through the history entries in case ;they result in no visible changes
|
||||||
while (historyDelta) {
|
while (historyDelta) {
|
||||||
try {
|
try {
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -229,7 +229,6 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
|||||||
export {
|
export {
|
||||||
restore,
|
restore,
|
||||||
restoreAppState,
|
restoreAppState,
|
||||||
restoreElement,
|
|
||||||
restoreElements,
|
restoreElements,
|
||||||
restoreLibraryItems,
|
restoreLibraryItems,
|
||||||
} from "./data/restore";
|
} from "./data/restore";
|
||||||
@ -282,7 +281,6 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
|
|||||||
export { Button } from "./components/Button";
|
export { Button } from "./components/Button";
|
||||||
export { Footer };
|
export { Footer };
|
||||||
export { MainMenu };
|
export { MainMenu };
|
||||||
export { Ellipsify } from "./components/Ellipsify";
|
|
||||||
export { useDevice } from "./components/App";
|
export { useDevice } from "./components/App";
|
||||||
export { WelcomeScreen };
|
export { WelcomeScreen };
|
||||||
export { LiveCollaborationTrigger };
|
export { LiveCollaborationTrigger };
|
||||||
|
|||||||
@ -83,7 +83,7 @@
|
|||||||
"@excalidraw/element": "0.18.0",
|
"@excalidraw/element": "0.18.0",
|
||||||
"@excalidraw/math": "0.18.0",
|
"@excalidraw/math": "0.18.0",
|
||||||
"@excalidraw/laser-pointer": "1.3.1",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "1.1.3",
|
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||||
"@excalidraw/random-username": "1.1.0",
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"@radix-ui/react-popover": "1.1.6",
|
"@radix-ui/react-popover": "1.1.6",
|
||||||
"@radix-ui/react-tabs": "1.1.3",
|
"@radix-ui/react-tabs": "1.1.3",
|
||||||
|
|||||||
@ -118,8 +118,7 @@ const renderLinearElementPointHighlight = (
|
|||||||
) => {
|
) => {
|
||||||
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
|
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
|
||||||
if (
|
if (
|
||||||
appState.selectedLinearElement?.isEditing &&
|
appState.editingLinearElement?.selectedPointsIndices?.includes(
|
||||||
appState.selectedLinearElement?.selectedPointsIndices?.includes(
|
|
||||||
hoverPointIndex,
|
hoverPointIndex,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -181,7 +180,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
point[0],
|
point[0],
|
||||||
point[1],
|
point[1],
|
||||||
(isOverlappingPoint
|
(isOverlappingPoint
|
||||||
? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
|
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||||
: radius) / appState.zoom.value,
|
: radius) / appState.zoom.value,
|
||||||
!isPhantomPoint,
|
!isPhantomPoint,
|
||||||
!isOverlappingPoint || isSelected,
|
!isOverlappingPoint || isSelected,
|
||||||
@ -449,7 +448,7 @@ const renderLinearPointHandles = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||||
const radius = appState.selectedLinearElement?.isEditing
|
const radius = appState.editingLinearElement
|
||||||
? POINT_HANDLE_SIZE
|
? POINT_HANDLE_SIZE
|
||||||
: POINT_HANDLE_SIZE / 2;
|
: POINT_HANDLE_SIZE / 2;
|
||||||
|
|
||||||
@ -471,8 +470,7 @@ const renderLinearPointHandles = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
let isSelected =
|
let isSelected =
|
||||||
!!appState.selectedLinearElement?.isEditing &&
|
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(idx);
|
|
||||||
// when element is a polygon, highlight the last point as well if first
|
// when element is a polygon, highlight the last point as well if first
|
||||||
// point is selected since they overlap and the last point tends to be
|
// point is selected since they overlap and the last point tends to be
|
||||||
// rendered on top
|
// rendered on top
|
||||||
@ -481,8 +479,7 @@ const renderLinearPointHandles = (
|
|||||||
element.polygon &&
|
element.polygon &&
|
||||||
!isSelected &&
|
!isSelected &&
|
||||||
idx === element.points.length - 1 &&
|
idx === element.points.length - 1 &&
|
||||||
!!appState.selectedLinearElement?.isEditing &&
|
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
|
||||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
|
|
||||||
) {
|
) {
|
||||||
isSelected = true;
|
isSelected = true;
|
||||||
}
|
}
|
||||||
@ -538,7 +535,7 @@ const renderLinearPointHandles = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
midPoints.forEach((segmentMidPoint) => {
|
midPoints.forEach((segmentMidPoint) => {
|
||||||
if (appState.selectedLinearElement?.isEditing || points.length === 2) {
|
if (appState.editingLinearElement || points.length === 2) {
|
||||||
renderSingleLinearPoint(
|
renderSingleLinearPoint(
|
||||||
context,
|
context,
|
||||||
appState,
|
appState,
|
||||||
@ -763,10 +760,7 @@ const _renderInteractiveScene = ({
|
|||||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||||
// ShapeCache returns empty hence making sure that we get the
|
// ShapeCache returns empty hence making sure that we get the
|
||||||
// correct element from visible elements
|
// correct element from visible elements
|
||||||
if (
|
if (appState.editingLinearElement?.elementId === element.id) {
|
||||||
appState.selectedLinearElement?.isEditing &&
|
|
||||||
appState.selectedLinearElement.elementId === element.id
|
|
||||||
) {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
|
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
|
||||||
}
|
}
|
||||||
@ -859,8 +853,7 @@ const _renderInteractiveScene = ({
|
|||||||
// correct element from visible elements
|
// correct element from visible elements
|
||||||
if (
|
if (
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
appState.selectedLinearElement?.isEditing &&
|
appState.editingLinearElement?.elementId === selectedElements[0].id
|
||||||
appState.selectedLinearElement.elementId === selectedElements[0].id
|
|
||||||
) {
|
) {
|
||||||
renderLinearPointHandles(
|
renderLinearPointHandles(
|
||||||
context,
|
context,
|
||||||
@ -891,7 +884,7 @@ const _renderInteractiveScene = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Paint selected elements
|
// Paint selected elements
|
||||||
if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
|
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||||
|
|
||||||
const isSingleLinearElementSelected =
|
const isSingleLinearElementSelected =
|
||||||
|
|||||||
@ -1,16 +1,9 @@
|
|||||||
import { throttleRAF } from "@excalidraw/common";
|
import { throttleRAF } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import { renderElement } from "@excalidraw/element";
|
||||||
getTargetFrame,
|
|
||||||
isInvisiblySmallElement,
|
|
||||||
renderElement,
|
|
||||||
shouldApplyFrameClip,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
||||||
|
|
||||||
import { frameClip } from "./staticScene";
|
|
||||||
|
|
||||||
import type { NewElementSceneRenderConfig } from "../scene/types";
|
import type { NewElementSceneRenderConfig } from "../scene/types";
|
||||||
|
|
||||||
const _renderNewElementScene = ({
|
const _renderNewElementScene = ({
|
||||||
@ -36,37 +29,11 @@ const _renderNewElementScene = ({
|
|||||||
normalizedHeight,
|
normalizedHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
context.save();
|
|
||||||
|
|
||||||
// Apply zoom
|
// Apply zoom
|
||||||
|
context.save();
|
||||||
context.scale(appState.zoom.value, appState.zoom.value);
|
context.scale(appState.zoom.value, appState.zoom.value);
|
||||||
|
|
||||||
if (newElement && newElement.type !== "selection") {
|
if (newElement && newElement.type !== "selection") {
|
||||||
// e.g. when creating arrows and we're still below the arrow drag distance
|
|
||||||
// threshold
|
|
||||||
// (for now we skip render only with elements while we're creating to be
|
|
||||||
// safe)
|
|
||||||
if (isInvisiblySmallElement(newElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameId = newElement.frameId || appState.frameToHighlight?.id;
|
|
||||||
|
|
||||||
if (
|
|
||||||
frameId &&
|
|
||||||
appState.frameRendering.enabled &&
|
|
||||||
appState.frameRendering.clip
|
|
||||||
) {
|
|
||||||
const frame = getTargetFrame(newElement, elementsMap, appState);
|
|
||||||
|
|
||||||
if (
|
|
||||||
frame &&
|
|
||||||
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
|
|
||||||
) {
|
|
||||||
frameClip(frame, context, renderConfig, appState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderElement(
|
renderElement(
|
||||||
newElement,
|
newElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -79,8 +46,6 @@ const _renderNewElementScene = ({
|
|||||||
} else {
|
} else {
|
||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.restore();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -113,7 +113,7 @@ const strokeGrid = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const frameClip = (
|
const frameClip = (
|
||||||
frame: ExcalidrawFrameLikeElement,
|
frame: ExcalidrawFrameLikeElement,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
getDraggedElementsBounds,
|
getDraggedElementsBounds,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { isBoundToContainer } from "@excalidraw/element";
|
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getMaximumGroups } from "@excalidraw/element";
|
import { getMaximumGroups } from "@excalidraw/element";
|
||||||
|
|
||||||
@ -169,14 +169,8 @@ export const isSnappingEnabled = ({
|
|||||||
selectedElements: NonDeletedExcalidrawElement[];
|
selectedElements: NonDeletedExcalidrawElement[];
|
||||||
}) => {
|
}) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
// Allow snapping for lasso tool when dragging selected elements
|
|
||||||
// but not during lasso selection phase
|
|
||||||
const isLassoDragging =
|
|
||||||
app.state.activeTool.type === "lasso" &&
|
|
||||||
app.state.selectedElementsAreBeingDragged;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
|
app.state.activeTool.type !== "lasso" &&
|
||||||
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
||||||
(!app.state.objectsSnapModeEnabled &&
|
(!app.state.objectsSnapModeEnabled &&
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
@ -317,13 +311,20 @@ const getReferenceElements = (
|
|||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) =>
|
) => {
|
||||||
getVisibleAndNonSelectedElements(
|
const selectedFrames = selectedElements
|
||||||
|
.filter((element) => isFrameLikeElement(element))
|
||||||
|
.map((frame) => frame.id);
|
||||||
|
|
||||||
|
return getVisibleAndNonSelectedElements(
|
||||||
elements,
|
elements,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
|
).filter(
|
||||||
|
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getVisibleGaps = (
|
export const getVisibleGaps = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
|||||||
@ -908,6 +908,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -981,7 +982,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,
|
||||||
@ -1106,6 +1106,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -1173,7 +1174,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": {
|
||||||
@ -1319,6 +1319,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -1386,7 +1387,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,
|
||||||
@ -1649,6 +1649,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -1716,7 +1717,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,
|
||||||
@ -1979,6 +1979,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -2046,7 +2047,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": {
|
||||||
@ -2192,6 +2192,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -2257,7 +2258,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,
|
||||||
@ -2432,6 +2432,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -2499,7 +2500,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,
|
||||||
@ -2729,6 +2729,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -2801,7 +2802,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,
|
||||||
@ -3100,6 +3100,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -3167,7 +3168,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": {
|
||||||
@ -3592,6 +3592,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -3659,7 +3660,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,
|
||||||
@ -3692,14 +3692,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": 400692809,
|
"seed": 1116226695,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 81784553,
|
"versionNonce": 23633383,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@ -3724,14 +3724,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1150084233,
|
"versionNonce": 401146281,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@ -3914,6 +3914,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -3981,7 +3982,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,
|
||||||
@ -4236,6 +4236,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -4306,7 +4307,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,
|
||||||
@ -5520,6 +5520,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -5590,7 +5591,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,
|
||||||
@ -6736,6 +6736,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -6808,7 +6809,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,
|
||||||
@ -7670,6 +7670,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -7738,7 +7739,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,
|
||||||
@ -8669,6 +8669,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -8736,7 +8737,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,
|
||||||
@ -9659,6 +9659,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"editingFrame": null,
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
|
"editingLinearElement": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
@ -9729,7 +9730,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,
|
||||||
|
|||||||
@ -15,11 +15,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Click me
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Click me
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
@ -31,11 +27,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Excalidraw blog
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Excalidraw blog
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div
|
<div
|
||||||
@ -96,11 +88,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Help
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__shortcut"
|
class="dropdown-menu-item__shortcut"
|
||||||
@ -150,11 +138,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Open
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__shortcut"
|
class="dropdown-menu-item__shortcut"
|
||||||
@ -191,11 +175,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Save to...
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Save to...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -251,11 +231,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Export image...
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Export image...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__shortcut"
|
class="dropdown-menu-item__shortcut"
|
||||||
@ -304,11 +280,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Find on canvas
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Find on canvas
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__shortcut"
|
class="dropdown-menu-item__shortcut"
|
||||||
@ -365,11 +337,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Help
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__shortcut"
|
class="dropdown-menu-item__shortcut"
|
||||||
@ -406,11 +374,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Reset the canvas
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Reset the canvas
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
@ -455,11 +419,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
GitHub
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
@ -505,11 +465,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Follow us
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Follow us
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
@ -549,11 +505,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Discord chat
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Discord chat
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -590,11 +542,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__text"
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<span
|
Dark mode
|
||||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
|
||||||
>
|
|
||||||
Dark mode
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="dropdown-menu-item__shortcut"
|
class="dropdown-menu-item__shortcut"
|
||||||
@ -688,7 +636,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"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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" })]);
|
||||||
|
|||||||
@ -60,11 +60,7 @@ describe("restoreElements", () => {
|
|||||||
const rectElement = API.createElement({ type: "rectangle" });
|
const rectElement = API.createElement({ type: "rectangle" });
|
||||||
mockSizeHelper.mockImplementation(() => true);
|
mockSizeHelper.mockImplementation(() => true);
|
||||||
|
|
||||||
expect(
|
expect(restore.restoreElements([rectElement], null).length).toBe(0);
|
||||||
restore.restoreElements([rectElement], null, {
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
}),
|
|
||||||
).toEqual([expect.objectContaining({ isDeleted: true })]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should restore text element correctly passing value for each attribute", () => {
|
it("should restore text element correctly passing value for each attribute", () => {
|
||||||
@ -89,23 +85,6 @@ describe("restoreElements", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not delete empty text element when opts.deleteInvisibleElements is not defined", () => {
|
|
||||||
const textElement = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
text: "",
|
|
||||||
isDeleted: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const restoredElements = restore.restoreElements([textElement], null);
|
|
||||||
|
|
||||||
expect(restoredElements).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: textElement.id,
|
|
||||||
isDeleted: false,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
|
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
|
||||||
const textElement: any = API.createElement({
|
const textElement: any = API.createElement({
|
||||||
type: "text",
|
type: "text",
|
||||||
@ -118,9 +97,10 @@ describe("restoreElements", () => {
|
|||||||
textElement.font = "10 unknown";
|
textElement.font = "10 unknown";
|
||||||
|
|
||||||
expect(textElement.isDeleted).toBe(false);
|
expect(textElement.isDeleted).toBe(false);
|
||||||
const restoredText = restore.restoreElements([textElement], null, {
|
const restoredText = restore.restoreElements(
|
||||||
deleteInvisibleElements: true,
|
[textElement],
|
||||||
})[0] as ExcalidrawTextElement;
|
null,
|
||||||
|
)[0] as ExcalidrawTextElement;
|
||||||
expect(restoredText.isDeleted).toBe(true);
|
expect(restoredText.isDeleted).toBe(true);
|
||||||
expect(restoredText).toMatchSnapshot({
|
expect(restoredText).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
@ -197,16 +177,13 @@ describe("restoreElements", () => {
|
|||||||
y: 0,
|
y: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoredElements = restore.restoreElements([arrowElement], null, {
|
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const restoredArrow = restoredElements[0] as
|
const restoredArrow = restoredElements[0] as
|
||||||
| ExcalidrawArrowElement
|
| ExcalidrawArrowElement
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
expect(restoredArrow).not.toBeUndefined();
|
expect(restoredArrow).toBeUndefined();
|
||||||
expect(restoredArrow?.isDeleted).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||||
@ -871,7 +848,6 @@ describe("repairing bindings", () => {
|
|||||||
let restoredElements = restore.restoreElements(
|
let restoredElements = restore.restoreElements(
|
||||||
[container, invisibleBoundElement, boundElement],
|
[container, invisibleBoundElement, boundElement],
|
||||||
null,
|
null,
|
||||||
{ deleteInvisibleElements: true },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(restoredElements).toEqual([
|
expect(restoredElements).toEqual([
|
||||||
@ -879,11 +855,6 @@ describe("repairing bindings", () => {
|
|||||||
id: container.id,
|
id: container.id,
|
||||||
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
id: invisibleBoundElement.id,
|
|
||||||
containerId: container.id,
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: boundElement.id,
|
id: boundElement.id,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
@ -893,7 +864,7 @@ describe("repairing bindings", () => {
|
|||||||
restoredElements = restore.restoreElements(
|
restoredElements = restore.restoreElements(
|
||||||
[container, invisibleBoundElement, boundElement],
|
[container, invisibleBoundElement, boundElement],
|
||||||
null,
|
null,
|
||||||
{ repairBindings: true, deleteInvisibleElements: true },
|
{ repairBindings: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(restoredElements).toEqual([
|
expect(restoredElements).toEqual([
|
||||||
@ -901,11 +872,6 @@ describe("repairing bindings", () => {
|
|||||||
id: container.id,
|
id: container.id,
|
||||||
boundElements: [],
|
boundElements: [],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
id: invisibleBoundElement.id,
|
|
||||||
containerId: container.id,
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: boundElement.id,
|
id: boundElement.id,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
|
|||||||
@ -315,12 +315,7 @@ describe("Test dragCreate", () => {
|
|||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements.length).toEqual(0);
|
||||||
expect.objectContaining({
|
|
||||||
type: "arrow",
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line", async () => {
|
it("line", async () => {
|
||||||
@ -349,12 +344,7 @@ describe("Test dragCreate", () => {
|
|||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements.length).toEqual(0);
|
||||||
expect.objectContaining({
|
|
||||||
type: "line",
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -57,7 +57,7 @@ describe("export", () => {
|
|||||||
blob: pngBlob,
|
blob: pngBlob,
|
||||||
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
await API.drop([{ kind: "file", file: pngBlobEmbedded }]);
|
await API.drop(pngBlobEmbedded);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@ -94,12 +94,7 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded png (legacy v1)", async () => {
|
it("import embedded png (legacy v1)", async () => {
|
||||||
await API.drop([
|
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
||||||
{
|
|
||||||
kind: "file",
|
|
||||||
file: await API.loadFile("./fixtures/test_embedded_v1.png"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "test" }),
|
expect.objectContaining({ type: "text", text: "test" }),
|
||||||
@ -108,12 +103,7 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded png (v2)", async () => {
|
it("import embedded png (v2)", async () => {
|
||||||
await API.drop([
|
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
|
||||||
{
|
|
||||||
kind: "file",
|
|
||||||
file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
@ -122,12 +112,7 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded svg (legacy v1)", async () => {
|
it("import embedded svg (legacy v1)", async () => {
|
||||||
await API.drop([
|
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
|
||||||
{
|
|
||||||
kind: "file",
|
|
||||||
file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "test" }),
|
expect.objectContaining({ type: "text", text: "test" }),
|
||||||
@ -136,12 +121,7 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded svg (v2)", async () => {
|
it("import embedded svg (v2)", async () => {
|
||||||
await API.drop([
|
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
|
||||||
{
|
|
||||||
kind: "file",
|
|
||||||
file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
export const DEER_IMAGE_DIMENSIONS = {
|
|
||||||
width: 318,
|
|
||||||
height: 335,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SMILEY_IMAGE_DIMENSIONS = {
|
|
||||||
width: 56,
|
|
||||||
height: 77,
|
|
||||||
};
|
|
||||||
@ -25,7 +25,6 @@ import { Excalidraw } from "../index";
|
|||||||
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
||||||
import * as blobModule from "../data/blob";
|
import * as blobModule from "../data/blob";
|
||||||
|
|
||||||
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
import {
|
import {
|
||||||
@ -745,6 +744,11 @@ describe("freedraw", () => {
|
|||||||
//image
|
//image
|
||||||
//TODO: currently there is no test for pixel colors at flipped positions.
|
//TODO: currently there is no test for pixel colors at flipped positions.
|
||||||
describe("image", () => {
|
describe("image", () => {
|
||||||
|
const smileyImageDimensions = {
|
||||||
|
width: 56,
|
||||||
|
height: 77,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||||
h.state.height = 1000;
|
h.state.height = 1000;
|
||||||
@ -752,8 +756,8 @@ describe("image", () => {
|
|||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockHTMLImageElement(
|
mockHTMLImageElement(
|
||||||
SMILEY_IMAGE_DIMENSIONS.width,
|
smileyImageDimensions.width,
|
||||||
SMILEY_IMAGE_DIMENSIONS.height,
|
smileyImageDimensions.height,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -478,43 +478,33 @@ export class API {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
|
static drop = async (blob: Blob) => {
|
||||||
|
|
||||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||||
|
const text = await new Promise<string>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsText(blob);
|
||||||
|
} catch (error: any) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
|
const files = [blob] as File[] & { item: (index: number) => File };
|
||||||
|
|
||||||
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
|
|
||||||
files.item = (index: number) => files[index];
|
files.item = (index: number) => files[index];
|
||||||
|
|
||||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||||
value: {
|
value: {
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
|
|
||||||
files,
|
files,
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
|
|
||||||
items: items.map((item, idx) => {
|
|
||||||
if (item.kind === "string") {
|
|
||||||
return {
|
|
||||||
kind: "string",
|
|
||||||
type: item.type,
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
|
|
||||||
getAsString: (cb: (text: string) => any) => cb(item.value),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "file",
|
|
||||||
type: item.type || item.file.type,
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
|
|
||||||
getAsFile: () => item.file,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
|
|
||||||
getData: (type: string) => {
|
getData: (type: string) => {
|
||||||
return items.find((item) => item.type === "string" && item.type === type) || "";
|
if (type === blob.type || type === "text") {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
},
|
},
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
|
types: [blob.type],
|
||||||
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Object.defineProperty(fileDropEvent, "clientX", {
|
Object.defineProperty(fileDropEvent, "clientX", {
|
||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
export const INITIALIZED_IMAGE_PROPS = {
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
};
|
|
||||||
@ -58,35 +58,3 @@ export const mockHTMLImageElement = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
|
|
||||||
export const mockMultipleHTMLImageElements = (
|
|
||||||
sizes: (readonly [number, number])[],
|
|
||||||
) => {
|
|
||||||
const _sizes = [...sizes];
|
|
||||||
|
|
||||||
vi.stubGlobal(
|
|
||||||
"Image",
|
|
||||||
class extends Image {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
const size = _sizes.shift();
|
|
||||||
if (!size) {
|
|
||||||
throw new Error("Insufficient sizes");
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(this, "naturalWidth", {
|
|
||||||
value: size[0],
|
|
||||||
});
|
|
||||||
Object.defineProperty(this, "naturalHeight", {
|
|
||||||
value: size[1],
|
|
||||||
});
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
this.onload?.({} as Event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -47,43 +47,42 @@ class DataTransferItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataTransferItemList extends Array<DataTransferItem> {
|
class DataTransferList {
|
||||||
|
items: DataTransferItem[] = [];
|
||||||
|
|
||||||
add(data: string | File, type: string = ""): void {
|
add(data: string | File, type: string = ""): void {
|
||||||
if (typeof data === "string") {
|
if (typeof data === "string") {
|
||||||
this.push(new DataTransferItem("string", type, data));
|
this.items.push(new DataTransferItem("string", type, data));
|
||||||
} else if (data instanceof File) {
|
} else if (data instanceof File) {
|
||||||
this.push(new DataTransferItem("file", type, data));
|
this.items.push(new DataTransferItem("file", type, data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.clear();
|
this.items = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataTransfer {
|
class DataTransfer {
|
||||||
public items: DataTransferItemList = new DataTransferItemList();
|
public items: DataTransferList = new DataTransferList();
|
||||||
|
private _types: Record<string, string> = {};
|
||||||
|
|
||||||
get files() {
|
get files() {
|
||||||
return this.items
|
return this.items.items
|
||||||
.filter((item) => item.kind === "file")
|
.filter((item) => item.kind === "file")
|
||||||
.map((item) => item.getAsFile()!);
|
.map((item) => item.getAsFile()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(data: string | File, type: string = ""): void {
|
add(data: string | File, type: string = ""): void {
|
||||||
if (typeof data === "string") {
|
this.items.add(data, type);
|
||||||
this.items.add(data, type);
|
|
||||||
} else {
|
|
||||||
this.items.add(data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(type: string, value: string) {
|
setData(type: string, value: string) {
|
||||||
this.items.add(value, type);
|
this._types[type] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
getData(type: string) {
|
getData(type: string) {
|
||||||
return this.items.find((item) => item.type === type)?.data || "";
|
return this._types[type] || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user