Compare commits

...

29 Commits

Author SHA1 Message Date
dwelle 93c33fef20 feat: support importing obsidian.md files 2024-07-19 12:39:35 +02:00
BlueGreenMagick df8875a497 fix: freedraw jittering (#8238) 2024-07-14 08:44:47 +00:00
David Luzar 6fbc44fd1f fix: messed up env variable (#8231) 2024-07-11 14:33:35 +02:00
Aakansha Doshi d25a7d365b feat: upgrade mermaid-to-excalidraw to v1.1.0 (#8226)
* feat: upgrade mermaid-to-excalidraw to v1.1.0

* fixes

* upgrade and remove config as its redundant

* lint

* upgrade to v1.1.0
2024-07-10 20:57:43 +05:30
David Luzar e52c2cd0b6 fix: log allowed events (#8224) 2024-07-09 12:16:14 +02:00
David Luzar 96eeec5119 feat: bump max file size (#8220) 2024-07-08 18:35:13 +02:00
Hamir Mahal f5221d521b ci: upgrade gh actions checkout and setup-node to v4 (#8168)
fix: usage of `node12 which is deprecated`
2024-07-08 14:26:25 +05:30
Alexandre Lemoine db2c235cd4 Fix : exportToCanvas() doc example (#8127) 2024-07-08 08:52:05 +00:00
David Luzar 148b895f46 feat: smarter preferred lang detection (#8205) 2024-07-04 17:55:35 +02:00
DDDDD12138 d9258a736b chore: Consolidate i18n import in LanguageList component (#8201) 2024-07-04 17:34:16 +02:00
zsviczian 2e1f08c796 fix: memory leak - scene.destroy() and window.launchQueue (#8198) 2024-07-02 22:08:02 +02:00
David Luzar 1d5b41dabb fix: stop updating text versions on init (#8191) 2024-07-01 14:04:58 +02:00
Márk Tolmács 66a2f24296 fix: Add binding update to manual stat changes (#8183)
Manual stats changes now respect previous element bindings.
2024-07-01 09:45:31 +02:00
Márk Tolmács 04668d8263 fix: Binding after duplicating is now applied for both the old and duplicate shapes (#8185)
Using ALT/OPT + drag to clone does not transfer the bindings (or leaves the duplicates in place of the old one , which are also not bound).

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 15:28:48 +02:00
David Luzar abbeed3d5f feat: support Stats bound text fontSize editing (#8187) 2024-06-28 13:52:29 +02:00
Márk Tolmács ba8c09d529 fix: Incorrect point offsetting in LinearElementEditor.movePoints() (#8145)
The LinearElementEditor.movePoints() function incorrectly calculates the offset for local linear element points when multiple targetPoints are provided, one of those target points is index === 0 AND the other points are moved in the negative direction, and ending up with negative local coordinates.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 12:23:10 +02:00
David Luzar 744b3e5d09 fix: stats state leaking & race conds (#8177) 2024-06-26 23:31:08 +02:00
Esteban Romo 6ba9bd60e8 feat: allow props.initialData to be a function (#8135) 2024-06-24 11:36:49 +02:00
zsviczian a1ffa064df fix: only bind arrow (#8152)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-06-19 10:55:35 +02:00
David Luzar 4dc4590f24 fix: repair invalid binding on restore & fix type check (#8133) 2024-06-13 19:42:08 +02:00
Ryan Di d2f67e619f feat: editable element stats (#6382)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-06-12 19:49:46 +02:00
David Luzar 22b39277f5 feat: paste as mermaid if applicable (#8116) 2024-06-11 19:19:22 +02:00
Sunil 63dee03ef0 docs: remove extra braces in callback JSX (#8087)
Fix: Syantax error
2024-05-31 10:48:31 +00:00
David Luzar 08b13f971d fix: wysiwyg blur-submit on mobile (#8075) 2024-05-28 16:18:02 +02:00
David Luzar 69f4cc70cb feat: stop autoselecting text on text edit on mobile (#8076) 2024-05-28 16:17:52 +02:00
Ryan Di 860308eb27 feat: create new text with width (#8038)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-05-28 15:53:52 +02:00
David Luzar 4eb9463f26 fix: restore linear dimensions from points (#8062) 2024-05-27 10:36:58 +02:00
Aakansha Doshi 6ed6131169 build: run tests on master branch (#8072)
* build: run tests on master branch

* lint
2024-05-27 12:44:20 +05:30
David Luzar 1ed98f9c93 fix: lp plus url (#8056) 2024-05-24 09:10:14 +00:00
83 changed files with 4807 additions and 735 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DISABLE_TRACKING=true
VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
+1 -1
View File
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_DISABLE_TRACKING=
VITE_APP_ENABLE_TRACKING=false
+6 -3
View File
@@ -1,14 +1,17 @@
name: Tests
on: pull_request
on:
pull_request:
push:
branches: master
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: 18.x
- name: Install and test
@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
```jsx showLineNumbers
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
}
```
@@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" />
</div>
<div style={{ height: "400px" }}>
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
/>
</div>
</>
+6 -23
View File
@@ -1,5 +1,4 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState";
@@ -22,7 +21,6 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
@@ -93,7 +91,7 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
@@ -121,6 +119,8 @@ import {
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill();
@@ -172,11 +172,6 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@@ -322,19 +317,15 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state
// ---------------------------------------------------------------------------
@@ -490,11 +481,7 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
@@ -595,10 +582,6 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@@ -1,8 +1,7 @@
import { useSetAtom } from "jotai";
import React from "react";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();
@@ -0,0 +1,25 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chienese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};
@@ -0,0 +1,15 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};
+2 -2
View File
@@ -6,7 +6,7 @@ import {
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "./LanguageList";
import { LanguageList } from "../app-language/LanguageList";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -34,7 +34,7 @@ export const AppMainMenu: React.FC<{
<MainMenu.ItemLink
icon={ExcalLogo}
href={`${
import.meta.env.VITE_APP_PLUS_APP
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className=""
>
+1
View File
@@ -25,6 +25,7 @@
margin-bottom: auto;
margin-inline-start: auto;
margin-inline-end: 0.6em;
z-index: var(--zIndex-layerUI);
svg {
width: 1.2rem;
+2 -2
View File
@@ -31,8 +31,8 @@
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
+6 -1
View File
@@ -64,7 +64,12 @@ export default defineConfig({
workbox: {
// Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"lz-string",
],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
+2
View File
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
+1 -1
View File
@@ -104,7 +104,7 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
showStats: appState.showStats,
stats: appState.stats,
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
@@ -131,7 +131,12 @@ export const actionFinalize = register({
-1,
arrayToMap(elements),
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
);
}
}
+1 -1
View File
@@ -124,7 +124,7 @@ const flipElements = (
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
app,
elementsMap,
isBindingEnabled(appState),
[],
);
@@ -5,21 +5,22 @@ import { StoreAction } from "../store";
export const actionToggleStats = register({
name: "stats",
label: "stats.title",
label: "stats.fullTitle",
icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],
perform(elements, appState) {
return {
appState: {
...appState,
showStats: !this.checked!(appState),
stats: { ...appState.stats, open: !this.checked!(appState) },
},
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.showStats,
checked: (appState) => appState.stats.open,
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});
+2 -1
View File
@@ -135,7 +135,8 @@ export type ActionName =
| "createContainerFromText"
| "wrapTextInContainer"
| "commandPalette"
| "autoResize";
| "autoResize"
| "elementStats";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
+10 -7
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
export const trackEvent = (
category: string,
@@ -9,17 +9,20 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
typeof window === "undefined" ||
import.meta.env.VITE_WORKER_ID ||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
return;
}
+6 -2
View File
@@ -5,6 +5,7 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
STATS_PANELS,
THEME,
} from "./constants";
import type { AppState, NormalizedZoomValue } from "./types";
@@ -80,7 +81,10 @@ export const getDefaultAppState = (): Omit<
selectedElementsAreBeingDragged: false,
selectionElement: null,
shouldCacheIgnoreZoom: false,
showStats: false,
stats: {
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
@@ -196,7 +200,7 @@ const APP_STATE_STORAGE_CONF = (<
},
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
+214 -185
View File
@@ -224,16 +224,9 @@ import type {
ScrollBars,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import { findShapeByKey, getElementShape } from "../shapes";
import type { GeometricShape } from "../../utils/geometry/shape";
import {
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
getSelectionBoxShape,
} from "../../utils/geometry/shape";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import type {
AppClassProperties,
@@ -330,6 +323,7 @@ import {
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
isValidTextContainer,
measureText,
@@ -422,7 +416,6 @@ import {
hitElementBoundText,
hitElementBoundingBoxOnly,
hitElementItself,
shouldTestInside,
} from "../element/collision";
import { textWysiwyg } from "../element/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars";
@@ -434,6 +427,7 @@ import {
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -1696,6 +1690,7 @@ class App extends React.Component<AppProps, AppState> {
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
@@ -2131,95 +2126,96 @@ class App extends React.Component<AppProps, AppState> {
});
};
private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
return;
public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
return;
}
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
let didUpdate = false;
let editingElement: AppState["editingElement"] | null = null;
if (actionResult.elements) {
actionResult.elements.forEach((element) => {
if (
this.state.editingElement?.id === element.id &&
this.state.editingElement !== element &&
isNonDeletedElement(element)
) {
editingElement = element;
}
});
this.scene.replaceAllElements(actionResult.elements);
didUpdate = true;
}
if (actionResult.files) {
this.files = actionResult.replaceFiles
? actionResult.files
: { ...this.files, ...actionResult.files };
this.addNewImagesToImageCache();
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
const theme =
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
const name = actionResult?.appState?.name ?? this.state.name;
const errorMessage =
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
}
let editingElement: AppState["editingElement"] | null = null;
if (actionResult.elements) {
actionResult.elements.forEach((element) => {
if (
this.state.editingElement?.id === element.id &&
this.state.editingElement !== element &&
isNonDeletedElement(element)
) {
editingElement = element;
}
if (typeof this.props.zenModeEnabled !== "undefined") {
zenModeEnabled = this.props.zenModeEnabled;
}
if (typeof this.props.gridModeEnabled !== "undefined") {
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
}
editingElement =
editingElement || actionResult.appState?.editingElement || null;
if (editingElement?.isDeleted) {
editingElement = null;
}
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
});
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
didUpdate = true;
}
this.scene.replaceAllElements(actionResult.elements);
}
if (actionResult.files) {
this.files = actionResult.replaceFiles
? actionResult.files
: { ...this.files, ...actionResult.files };
this.addNewImagesToImageCache();
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.shouldUpdateSnapshot();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.shouldCaptureIncrement();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
const theme =
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
const name = actionResult?.appState?.name ?? this.state.name;
const errorMessage =
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
}
if (typeof this.props.zenModeEnabled !== "undefined") {
zenModeEnabled = this.props.zenModeEnabled;
}
if (typeof this.props.gridModeEnabled !== "undefined") {
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
}
editingElement =
editingElement || actionResult.appState?.editingElement || null;
if (editingElement?.isDeleted) {
editingElement = null;
}
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
});
}
},
);
if (!didUpdate && actionResult.storeAction !== StoreAction.NONE) {
this.scene.triggerUpdate();
}
});
// Lifecycle
@@ -2286,7 +2282,11 @@ class App extends React.Component<AppProps, AppState> {
}
let initialData = null;
try {
initialData = (await this.props.initialData) || null;
if (typeof this.props.initialData === "function") {
initialData = (await this.props.initialData()) || null;
} else {
initialData = (await this.props.initialData) || null;
}
if (initialData?.libraryItems) {
this.library
.updateLibrary({
@@ -2489,7 +2489,9 @@ class App extends React.Component<AppProps, AppState> {
}
public componentWillUnmount() {
(window as any).launchQueue?.setConsumer(() => {});
this.renderer.destroy();
this.scene.destroy();
this.scene = new Scene();
this.fonts = new Fonts({ scene: this.scene });
this.renderer = new Renderer(this.scene);
@@ -2498,7 +2500,6 @@ class App extends React.Component<AppProps, AppState> {
this.resizeObserver?.disconnect();
this.unmounted = true;
this.removeEventListeners();
this.scene.destroy();
this.library.destroy();
this.laserTrails.stop();
this.eraserTrail.stop();
@@ -2810,7 +2811,7 @@ class App extends React.Component<AppProps, AppState> {
nonDeletedElementsMap,
),
),
this,
this.scene.getNonDeletedElementsMap(),
);
}
@@ -3048,6 +3049,31 @@ class App extends React.Component<AppProps, AppState> {
retainSeed: isPlainPaste,
});
} else if (data.text) {
if (data.text && isMaybeMermaidDefinition(data.text)) {
const api = await import("@excalidraw/mermaid-to-excalidraw");
try {
const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text);
const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true,
});
this.addElementsFromPasteOrLibrary({
elements,
files,
position: "cursor",
});
return;
} catch (err: any) {
console.warn(
`parsing pasted text as mermaid definition failed: ${err.message}`,
);
}
}
const nonEmptyLines = normalizeEOL(data.text)
.split(/\n+/)
.map((s) => s.trim())
@@ -3972,7 +3998,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this,
this.scene.getNonDeletedElementsMap(),
),
});
@@ -4143,7 +4169,7 @@ class App extends React.Component<AppProps, AppState> {
if (isArrowKey(event.key)) {
bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement),
this,
this.scene.getNonDeletedElementsMap(),
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
);
@@ -4419,6 +4445,11 @@ class App extends React.Component<AppProps, AppState> {
element,
excalidrawContainer: this.excalidrawContainerRef.current,
app: this,
// when text is selected, it's hard (at least on iOS) to re-position the
// caret (i.e. deselect). There's not much use for always selecting
// the text on edit anyway (and users can select-all from contextmenu
// if needed)
autoSelect: !this.device.isTouchScreen,
});
// deselect all other elements when inserting text
this.deselectElements();
@@ -4450,59 +4481,6 @@ class App extends React.Component<AppProps, AppState> {
return null;
}
/**
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
public getElementShape(element: ExcalidrawElement): GeometricShape {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return shouldTestInside(element)
? getClosedCurveShape(
element,
roughShape,
[element.x, element.y],
element.angle,
[cx, cy],
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
}
}
}
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
const boundTextElement = getBoundTextElement(
element,
@@ -4511,18 +4489,24 @@ class App extends React.Component<AppProps, AppState> {
if (boundTextElement) {
if (element.type === "arrow") {
return this.getElementShape({
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
this.scene.getNonDeletedElementsMap(),
),
});
return getElementShape(
{
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
this.scene.getNonDeletedElementsMap(),
),
},
this.scene.getNonDeletedElementsMap(),
);
}
return this.getElementShape(boundTextElement);
return getElementShape(
boundTextElement,
this.scene.getNonDeletedElementsMap(),
);
}
return null;
@@ -4561,7 +4545,10 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element: elementWithHighestZIndex,
shape: this.getElementShape(elementWithHighestZIndex),
shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
// when overlapping, we would like to be more precise
// this also avoids the need to update past tests
threshold: this.getElementHitThreshold() / 2,
@@ -4666,7 +4653,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element,
shape: this.getElementShape(element),
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element)
@@ -4698,7 +4685,10 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element: elements[index],
shape: this.getElementShape(elements[index]),
shape: getElementShape(
elements[index],
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(),
})
) {
@@ -4718,6 +4708,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
insertAtParentCenter = true,
container,
autoEdit = true,
}: {
/** X position to insert text at */
sceneX: number;
@@ -4726,6 +4717,7 @@ class App extends React.Component<AppProps, AppState> {
/** whether to attempt to insert at element center if applicable */
insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null;
autoEdit?: boolean;
}) => {
let shouldBindToContainer = false;
@@ -4858,13 +4850,16 @@ class App extends React.Component<AppProps, AppState> {
}
}
this.setState({
editingElement: element,
});
this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement,
});
if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement,
});
} else {
this.setState({
draggingElement: element,
multiElement: null,
});
}
};
private handleCanvasDoubleClick = (
@@ -4951,7 +4946,10 @@ class App extends React.Component<AppProps, AppState> {
x: sceneX,
y: sceneY,
element: container,
shape: this.getElementShape(container),
shape: getElementShape(
container,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(),
})
) {
@@ -5643,7 +5641,10 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX,
y: scenePointerY,
element,
shape: this.getElementShape(element),
shape: getElementShape(
element,
this.scene.getNonDeletedElementsMap(),
),
})
) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@@ -5899,7 +5900,6 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState);
return;
} else if (
this.state.activeTool.type === "arrow" ||
this.state.activeTool.type === "line"
@@ -6020,6 +6020,7 @@ class App extends React.Component<AppProps, AppState> {
);
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.device.editor.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
@@ -6693,6 +6694,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
insertAtParentCenter: !event.altKey,
container,
autoEdit: false,
});
resetCursor(this.interactiveCanvas);
@@ -6761,7 +6763,7 @@ class App extends React.Component<AppProps, AppState> {
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this,
this.scene.getNonDeletedElementsMap(),
);
this.scene.insertElement(element);
this.setState({
@@ -7023,7 +7025,7 @@ class App extends React.Component<AppProps, AppState> {
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this,
this.scene.getNonDeletedElementsMap(),
);
this.scene.insertElement(element);
@@ -7493,7 +7495,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this,
this.scene.getNonDeletedElementsMap(),
),
});
@@ -8014,7 +8016,7 @@ class App extends React.Component<AppProps, AppState> {
draggingElement,
this.state,
pointerCoords,
this,
this.scene.getNonDeletedElementsMap(),
);
}
this.setState({ suggestedBindings: [], startBoundElement: null });
@@ -8043,6 +8045,28 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (isTextElement(draggingElement)) {
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: draggingElement.fontSize,
fontFamily: draggingElement.fontFamily,
}),
draggingElement.lineHeight,
);
if (draggingElement.width < minWidth) {
mutateElement(draggingElement, {
autoResize: true,
});
}
this.resetCursor();
this.handleTextWysiwyg(draggingElement, {
isExistingElement: true,
});
}
if (
activeTool.type !== "selection" &&
draggingElement &&
@@ -8482,7 +8506,10 @@ class App extends React.Component<AppProps, AppState> {
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
element: hitElement,
shape: this.getElementShape(hitElement),
shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement)
@@ -8550,7 +8577,7 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements(
linearElements,
this,
this.scene.getNonDeletedElementsMap(),
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
);
@@ -9038,7 +9065,7 @@ class App extends React.Component<AppProps, AppState> {
}): void => {
const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords,
this,
this.scene.getNonDeletedElementsMap(),
);
this.setState({
suggestedBindings:
@@ -9065,7 +9092,7 @@ class App extends React.Component<AppProps, AppState> {
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this,
this.scene.getNonDeletedElementsMap(),
);
if (
hoveredBindableElement != null &&
@@ -9410,6 +9437,7 @@ class App extends React.Component<AppProps, AppState> {
distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
this.state.zoom.value,
);
} else {
let [gridX, gridY] = getGridPoint(
@@ -9467,6 +9495,7 @@ class App extends React.Component<AppProps, AppState> {
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
this.state.zoom.value,
aspectRatio,
this.state.originSnapOffset,
);
@@ -9595,7 +9624,7 @@ class App extends React.Component<AppProps, AppState> {
) {
const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements,
this,
this.scene.getNonDeletedElementsMap(),
);
const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
<Shortcut
label={t("stats.title")}
label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
<Shortcut
@@ -27,6 +27,99 @@
& > * {
pointer-events: var(--ui-pointerEvents);
}
& > .Stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-top: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.statsItem {
margin-top: 8px;
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
white-space: nowrap;
margin: 0;
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
}
}
&__footer {
+16 -13
View File
@@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
@@ -64,6 +62,8 @@ import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
interface LayerUIProps {
actionManager: ActionManager;
@@ -241,6 +241,11 @@ const LayerUI = ({
elements,
);
const shouldShowStats =
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled;
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
@@ -353,6 +358,15 @@ const LayerUI = ({
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out />
)}
{shouldShowStats && (
<Stats
scene={app.scene}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
</div>
</div>
</FixedSideContainer>
@@ -542,17 +556,6 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
{appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
{appState.scrolledOutside && (
<button
type="button"
@@ -21,8 +21,6 @@ import { Section } from "./Section";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton";
import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels";
@@ -157,17 +155,6 @@ export const MobileMenu = ({
<>
{renderSidebars()}
{!appState.viewModeEnabled && renderToolbar()}
{!appState.openMenu && appState.showStats && (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
)}
<div
className="App-bottom-bar"
style={{
-54
View File
@@ -1,54 +0,0 @@
@import "../css/variables.module.scss";
.excalidraw {
.Stats {
position: absolute;
top: 64px;
right: 12px;
font-size: 12px;
z-index: 10;
pointer-events: var(--ui-pointerEvents);
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
.close {
float: right;
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
}
}
}
}
-108
View File
@@ -1,108 +0,0 @@
import React from "react";
import { getCommonBounds } from "../element/bounds";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getTargetElements } from "../scene";
import type { ExcalidrawProps, UIAppState } from "../types";
import { CloseIcon } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
export const Stats = (props: {
appState: UIAppState;
setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
return (
<div className="Stats">
<Island padding={2}>
<div className="close" onClick={props.onClose}>
{CloseIcon}
</div>
<h3>{t("stats.title")}</h3>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{props.elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
</tr>
{selectedElements.length === 1 && (
<tr>
<th colSpan={2}>{t("stats.element")}</th>
</tr>
)}
{selectedElements.length > 1 && (
<>
<tr>
<th colSpan={2}>{t("stats.selected")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{selectedElements.length}</td>
</tr>
</>
)}
{selectedElements.length > 0 && (
<>
<tr>
<td>{"x"}</td>
<td>{Math.round(selectedBoundingBox[0])}</td>
</tr>
<tr>
<td>{"y"}</td>
<td>{Math.round(selectedBoundingBox[1])}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>
{Math.round(
selectedBoundingBox[2] - selectedBoundingBox[0],
)}
</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>
{Math.round(
selectedBoundingBox[3] - selectedBoundingBox[1],
)}
</td>
</tr>
</>
)}
{selectedElements.length === 1 && (
<tr>
<td>{t("stats.angle")}</td>
<td>
{`${Math.round(
(selectedElements[0].angle * 180) / Math.PI,
)}°`}
</td>
</tr>
)}
{props.renderCustomStats?.(props.elements, props.appState)}
</tbody>
</table>
</Island>
</div>
);
};
@@ -0,0 +1,93 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface AngleProps {
element: ExcalidrawElement;
scene: Scene;
appState: AppState;
property: "angle";
}
const STEP_SIZE = 15;
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
return;
}
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
const Angle = ({ element, scene, appState, property }: AngleProps) => {
return (
<DragInput
label="A"
icon={angleIcon}
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
scene={scene}
appState={appState}
property={property}
/>
);
};
export default Angle;
@@ -0,0 +1,39 @@
import { InlineIcon } from "../InlineIcon";
import { collapseDownIcon, collapseUpIcon } from "../icons";
interface CollapsibleProps {
label: React.ReactNode;
// having it controlled so that the state is managed outside
// this is to keep the user's previous choice even when the
// Collapsible is unmounted
open: boolean;
openTrigger: () => void;
children: React.ReactNode;
}
const Collapsible = ({
label,
open,
openTrigger,
children,
}: CollapsibleProps) => {
return (
<>
<div
style={{
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
onClick={openTrigger}
>
{label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div>
{open && <>{children}</>}
</>
);
};
export default Collapsible;
@@ -0,0 +1,134 @@
import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface DimensionDragInputProps {
property: "width" | "height";
element: ExcalidrawElement;
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image";
};
const handleDimensionChange: DragInputCallbackType<
DimensionDragInputProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
if (origElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height;
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: origElement.width,
MIN_WIDTH_OR_HEIGHT,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: origElement.height,
MIN_WIDTH_OR_HEIGHT,
);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
origElement,
elementsMap,
);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
origElement,
elementsMap,
);
}
};
const DimensionDragInput = ({
property,
element,
scene,
appState,
}: DimensionDragInputProps) => {
const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
100;
return (
<DragInput
label={property === "width" ? "W" : "H"}
elements={[element]}
dragInputCallback={handleDimensionChange}
value={value}
editable={isPropertyEditable(element, property)}
scene={scene}
appState={appState}
property={property}
/>
);
};
export default DimensionDragInput;
@@ -0,0 +1,75 @@
.excalidraw {
.drag-input-container {
display: flex;
width: 100%;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.drag-input-label {
flex-shrink: 0;
border: 1px solid var(--default-border-color);
border-right: 0;
width: 2rem;
height: 2rem;
box-sizing: border-box;
color: var(--popup-text-color);
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.drag-input {
box-sizing: border-box;
width: 100%;
margin: 0;
font-size: 0.875rem;
font-family: inherit;
background-color: transparent;
color: var(--text-primary-color);
border: 0;
outline: none;
height: 2rem;
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
}
}
}
@@ -0,0 +1,311 @@
import { useEffect, useRef, useState } from "react";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement";
import clsx from "clsx";
import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon";
import type { StatsInputProperty } from "./utils";
import { SMALLEST_DELTA } from "./utils";
import { StoreAction } from "../../store";
import type Scene from "../../scene/Scene";
import "./DragInput.scss";
import type { AppState } from "../../types";
import { cloneJSON } from "../../utils";
export type DragInputCallbackType<
P extends StatsInputProperty,
E = ExcalidrawElement,
> = (props: {
accumulatedChange: number;
instantChange: number;
originalElements: readonly E[];
originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean;
nextValue?: number;
property: P;
scene: Scene;
originalAppState: AppState;
}) => void;
interface StatsDragInputProps<
T extends StatsInputProperty,
E = ExcalidrawElement,
> {
label: string | React.ReactNode;
icon?: React.ReactNode;
value: number | "Mixed";
elements: readonly E[];
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType<T, E>;
property: T;
scene: Scene;
appState: AppState;
}
const StatsDragInput = <
T extends StatsInputProperty,
E extends ExcalidrawElement = ExcalidrawElement,
>({
label,
icon,
dragInputCallback,
value,
elements,
editable = true,
shouldKeepAspectRatio,
property,
scene,
appState,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState(value.toString());
const stateRef = useRef<{
originalAppState: AppState;
originalElements: readonly E[];
lastUpdatedValue: string;
updatePending: boolean;
}>(null!);
if (!stateRef.current) {
stateRef.current = {
originalAppState: cloneJSON(appState),
originalElements: elements,
lastUpdatedValue: inputValue,
updatePending: false,
};
}
useEffect(() => {
const inputValue = value.toString();
setInputValue(inputValue);
stateRef.current.lastUpdatedValue = inputValue;
}, [value]);
const handleInputValue = (
updatedValue: string,
elements: readonly E[],
appState: AppState,
) => {
if (!stateRef.current.updatePending) {
return false;
}
stateRef.current.updatePending = false;
const parsed = Number(updatedValue);
if (isNaN(parsed)) {
setInputValue(value.toString());
return;
}
const rounded = Number(parsed.toFixed(2));
const original = Number(value);
// only update when
// 1. original was "Mixed" and we have a new value
// 2. original was not "Mixed" and the difference between a new value and previous value is greater
// than the smallest delta allowed, which is 0.01
// reason: idempotent to avoid unnecessary
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
stateRef.current.lastUpdatedValue = updatedValue;
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
originalElements: elements,
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
nextValue: rounded,
property,
scene,
originalAppState: appState,
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
};
const handleInputValueRef = useRef(handleInputValue);
handleInputValueRef.current = handleInputValue;
// make sure that clicking on canvas (which umounts the component)
// updates current input value (blur isn't triggered)
useEffect(() => {
const input = inputRef.current;
return () => {
const nextValue = input?.value;
if (nextValue) {
handleInputValueRef.current(
nextValue,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
}
};
}, [
// we need to track change of `editable` state as mount/unmount
// because react doesn't trigger `blur` when a an input is blurred due
// to being disabled (https://github.com/facebook/react/issues/9142).
// As such, if we keep rendering disabled inputs, then change in selection
// to an element that has a given property as non-editable would not trigger
// blur/unmount and wouldn't update the value.
editable,
]);
if (!editable) {
return null;
}
return (
<div
className={clsx("drag-input-container", !editable && "disabled")}
data-testid={label}
>
<div
className="drag-input-label"
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
}
let lastPointer: {
x: number;
y: number;
} | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null =
app.scene
.getNonDeletedElements()
.reduce((acc: ElementsMap, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map());
let originalElements: readonly E[] | null = elements.map(
(element) => originalElementsMap!.get(element.id) as E,
);
const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize");
const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (
lastPointer &&
originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null
) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
dragInputCallback({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
});
}
lastPointer = {
x: event.clientX,
y: event.clientY,
};
};
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = null;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
},
false,
);
}
}}
onPointerEnter={() => {
if (labelRef.current) {
labelRef.current.style.cursor = "ew-resize";
}
}}
>
{icon ? <InlineIcon icon={icon} /> : label}
</div>
<input
className="drag-input"
autoComplete="off"
spellCheck="false"
onKeyDown={(event) => {
if (editable) {
const eventTarget = event.target;
if (
eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER
) {
handleInputValue(eventTarget.value, elements, appState);
app.focusContainer();
}
}
}}
ref={inputRef}
value={inputValue}
onChange={(event) => {
stateRef.current.updatePending = true;
setInputValue(event.target.value);
}}
onFocus={(event) => {
event.target.select();
stateRef.current.originalElements = elements;
stateRef.current.originalAppState = cloneJSON(appState);
}}
onBlur={(event) => {
if (!inputValue) {
setInputValue(value.toString());
} else if (editable) {
handleInputValue(
event.target.value,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
}
}}
disabled={!editable}
/>
</div>
);
};
export default StatsDragInput;
@@ -0,0 +1,99 @@
import type {
ExcalidrawElement,
ExcalidrawTextElement,
} from "../../element/types";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { mutateElement } from "../../element/mutateElement";
import { getStepSizedValue } from "./utils";
import { fontSizeIcon } from "../icons";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isTextElement, redrawTextBoundingBox } from "../../element";
import { hasBoundTextElement } from "../../element/typeChecks";
import { getBoundTextElement } from "../../element/textElement";
interface FontSizeProps {
element: ExcalidrawElement;
scene: Scene;
appState: AppState;
property: "fontSize";
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const handleFontSizeChange: DragInputCallbackType<
FontSizeProps["property"],
ExcalidrawTextElement
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement || !isTextElement(latestElement)) {
return;
}
let nextFontSize;
if (nextValue !== undefined) {
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
} else if (origElement.type === "text") {
const originalFontSize = Math.round(origElement.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
}
if (nextFontSize) {
mutateElement(latestElement, {
fontSize: nextFontSize,
});
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
scene.getNonDeletedElementsMap(),
);
}
}
};
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
const _element = isTextElement(element)
? element
: hasBoundTextElement(element)
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
: null;
if (!_element) {
return null;
}
return (
<StatsDragInput
label="F"
value={Math.round(_element.fontSize * 10) / 10}
elements={[_element]}
dragInputCallback={handleFontSizeChange}
icon={fontSizeIcon}
appState={appState}
scene={scene}
property={property}
/>
);
};
export default FontSize;
@@ -0,0 +1,135 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
scene: Scene;
appState: AppState;
property: "angle";
}
const STEP_SIZE = 15;
const handleDegreeChange: DragInputCallbackType<
MultiAngleProps["property"]
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const editableLatestIndividualElements = originalElements
.map((el) => elementsMap.get(el.id))
.filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
const editableOriginalIndividualElements = originalElements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, property),
);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
for (const element of editableLatestIndividualElements) {
if (!element) {
continue;
}
mutateElement(
element,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
return;
}
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
const latestElement = editableLatestIndividualElements[i];
if (!latestElement) {
continue;
}
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(
latestElement,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
};
const MultiAngle = ({
elements,
scene,
appState,
property,
}: MultiAngleProps) => {
const editableLatestIndividualElements = elements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
const angles = editableLatestIndividualElements.map(
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
);
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
const editable = editableLatestIndividualElements.some((el) =>
isPropertyEditable(el, "angle"),
);
return (
<DragInput
label="A"
icon={angleIcon}
value={value}
elements={elements}
dragInputCallback={handleDegreeChange}
editable={editable}
appState={appState}
scene={scene}
property={property}
/>
);
};
export default MultiAngle;
@@ -0,0 +1,382 @@
import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
} from "../../element/textElement";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
interface MultiDimensionProps {
property: "width" | "height";
elements: readonly ExcalidrawElement[];
elementsMap: NonDeletedSceneElementsMap;
atomicUnits: AtomicUnit[];
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const getResizedUpdates = (
anchorX: number,
anchorY: number,
scale: number,
origElement: ExcalidrawElement,
) => {
const offsetX = origElement.x - anchorX;
const offsetY = origElement.y - anchorY;
const nextWidth = origElement.width * scale;
const nextHeight = origElement.height * scale;
const x = anchorX + offsetX * scale;
const y = anchorY + offsetY * scale;
return {
width: nextWidth,
height: nextHeight,
x,
y,
...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
...(isTextElement(origElement)
? { fontSize: origElement.fontSize * scale }
: {}),
};
};
const resizeElementInGroup = (
anchorX: number,
anchorY: number,
property: MultiDimensionProps["property"],
scale: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
origElement,
originalElementsMap,
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
mutateElement(
latestBoundTextElement,
{
fontSize: newFontSize,
},
false,
);
handleBindTextResize(
latestElement,
elementsMap,
property === "width" ? "e" : "s",
true,
);
}
}
};
const resizeGroup = (
nextWidth: number,
nextHeight: number,
initialHeight: number,
aspectRatio: number,
anchor: Point,
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
) => {
// keep aspect ratio for groups
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
const scale = nextHeight / initialHeight;
for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i];
const latestElement = latestElements[i];
resizeElementInGroup(
anchor[0],
anchor[1],
property,
scale,
latestElement,
origElement,
elementsMap,
originalElementsMap,
);
}
};
const handleDimensionChange: DragInputCallbackType<
MultiDimensionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
originalAppState,
shouldChangeByStepSize,
nextValue,
scene,
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
const nextWidth = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "width" ? Math.max(0, nextValue) : initialWidth,
);
const nextHeight = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "height" ? Math.max(0, nextValue) : initialHeight,
);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth =
property === "width" ? Math.max(0, nextValue) : latestElement.width;
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight =
property === "height"
? Math.max(0, nextValue)
: latestElement.height;
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
let nextWidth = Math.max(0, initialWidth + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, initialHeight + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
}
}
}
scene.triggerUpdate();
};
const MultiDimension = ({
property,
elements,
elementsMap,
atomicUnits,
scene,
appState,
}: MultiDimensionProps) => {
const sizes = useMemo(
() =>
atomicUnits.map((atomicUnit) => {
const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
if (elementsInUnit.length > 1) {
const [x1, y1, x2, y2] = getCommonBounds(
elementsInUnit.map((el) => el.latest),
);
return (
Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
);
}
const [el] = elementsInUnit;
return (
Math.round(
(property === "width" ? el.latest.width : el.latest.height) * 100,
) / 100
);
}),
[elementsMap, atomicUnits, property],
);
const value =
new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
const editable = sizes.length > 0;
return (
<DragInput
label={property === "width" ? "W" : "H"}
elements={elements}
dragInputCallback={handleDimensionChange}
value={value}
editable={editable}
appState={appState}
property={property}
scene={scene}
/>
);
};
export default MultiDimension;
@@ -0,0 +1,164 @@
import { isTextElement, redrawTextBoundingBox } from "../../element";
import { mutateElement } from "../../element/mutateElement";
import { hasBoundTextElement } from "../../element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { isInGroup } from "../../groups";
import type Scene from "../../scene/Scene";
import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue } from "./utils";
import type { AppState } from "../../types";
import { getBoundTextElement } from "../../element/textElement";
interface MultiFontSizeProps {
elements: readonly ExcalidrawElement[];
scene: Scene;
elementsMap: NonDeletedSceneElementsMap;
appState: AppState;
property: "fontSize";
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const getApplicableTextElements = (
elements: readonly (ExcalidrawElement | undefined)[],
elementsMap: NonDeletedSceneElementsMap,
) =>
elements.reduce(
(acc: ExcalidrawTextElement[], el) => {
if (!el || isInGroup(el)) {
return acc;
}
if (isTextElement(el)) {
acc.push(el);
return acc;
}
if (hasBoundTextElement(el)) {
const boundTextElement = getBoundTextElement(el, elementsMap);
if (boundTextElement) {
acc.push(boundTextElement);
return acc;
}
}
return acc;
},
[],
);
const handleFontSizeChange: DragInputCallbackType<
MultiFontSizeProps["property"],
ExcalidrawTextElement
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const latestTextElements = originalElements.map((el) =>
elementsMap.get(el.id),
) as ExcalidrawTextElement[];
let nextFontSize;
if (nextValue) {
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) {
mutateElement(
textElement,
{
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox(
textElement,
scene.getContainerElement(textElement),
elementsMap,
false,
);
}
scene.triggerUpdate();
} else {
const originalTextElements = originalElements as ExcalidrawTextElement[];
for (let i = 0; i < latestTextElements.length; i++) {
const latestElement = latestTextElements[i];
const originalElement = originalTextElements[i];
const originalFontSize = Math.round(originalElement.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
let nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
mutateElement(
latestElement,
{
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
elementsMap,
false,
);
}
scene.triggerUpdate();
}
};
const MultiFontSize = ({
elements,
scene,
appState,
property,
elementsMap,
}: MultiFontSizeProps) => {
const latestTextElements = getApplicableTextElements(elements, elementsMap);
if (!latestTextElements.length) {
return null;
}
const fontSizes = latestTextElements.map(
(textEl) => Math.round(textEl.fontSize * 10) / 10,
);
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
const editable = fontSizes.length > 0;
return (
<StatsDragInput
label="F"
icon={fontSizeIcon}
elements={latestTextElements}
dragInputCallback={handleFontSizeChange}
value={value}
editable={editable}
scene={scene}
property={property}
appState={appState}
/>
);
};
export default MultiFontSize;
@@ -0,0 +1,259 @@
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getCommonBounds, isTextElement } from "../../element";
import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
interface MultiPositionProps {
property: "x" | "y";
elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
atomicUnits: AtomicUnit[];
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const moveElements = (
property: MultiPositionProps["property"],
changeInTopX: number,
changeInTopY: number,
elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
) => {
for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i];
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
const newTopLeftX =
property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX;
const newTopLeftY =
property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
originalElementsMap,
false,
);
}
};
const moveGroupTo = (
nextX: number,
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1;
const offsetY = nextY - y1;
for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i];
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
continue;
}
// bound texts are moved with their containers
if (!isTextElement(latestElement) || !latestElement.containerId) {
const [cx, cy] = [
latestElement.x + latestElement.width / 2,
latestElement.y + latestElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
latestElement.x,
latestElement.y,
cx,
cy,
latestElement.angle,
);
moveElement(
topLeftX + offsetX,
topLeftY + offsetY,
origElement,
elementsMap,
originalElementsMap,
false,
);
}
}
};
const handlePositionChange: DragInputCallbackType<
MultiPositionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
originalElements,
originalAppState,
)) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const [x1, y1, ,] = getCommonBounds(
elementsInUnit.map((el) => el.latest!),
);
const newTopLeftX = property === "x" ? nextValue : x1;
const newTopLeftY = property === "y" ? nextValue : y1;
moveGroupTo(
newTopLeftX,
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
originalElementsMap,
scene,
);
} else {
const origElement = elementsInUnit[0]?.original;
const latestElement = elementsInUnit[0]?.latest;
if (
origElement &&
latestElement &&
isPropertyEditable(latestElement, property)
) {
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
originalElementsMap,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const change = shouldChangeByStepSize
? getStepSizedValue(accumulatedChange, STEP_SIZE)
: accumulatedChange;
const changeInTopX = property === "x" ? change : 0;
const changeInTopY = property === "y" ? change : 0;
moveElements(
property,
changeInTopX,
changeInTopY,
originalElements,
originalElements,
elementsMap,
originalElementsMap,
);
scene.triggerUpdate();
};
const MultiPosition = ({
property,
elements,
elementsMap,
atomicUnits,
scene,
appState,
}: MultiPositionProps) => {
const positions = useMemo(
() =>
atomicUnits.map((atomicUnit) => {
const elementsInUnit = Object.keys(atomicUnit)
.map((id) => elementsMap.get(id))
.filter((el) => el !== undefined) as ExcalidrawElement[];
// we're dealing with a group
if (elementsInUnit.length > 1) {
const [x1, y1] = getCommonBounds(elementsInUnit);
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
}
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}),
[atomicUnits, elementsMap, property],
);
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
return (
<StatsDragInput
label={property === "x" ? "X" : "Y"}
elements={elements}
dragInputCallback={handlePositionChange}
value={value}
property={property}
scene={scene}
appState={appState}
/>
);
};
export default MultiPosition;
@@ -0,0 +1,115 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface PositionProps {
property: "x" | "y";
element: ExcalidrawElement;
elementsMap: ElementsMap;
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
originalElementsMap,
);
return;
}
const changeInTopX = property === "x" ? accumulatedChange : 0;
const changeInTopY = property === "y" ? accumulatedChange : 0;
const newTopLeftX =
property === "x"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
: topLeftX + changeInTopX,
)
: topLeftX;
const newTopLeftY =
property === "y"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
: topLeftY + changeInTopY,
)
: topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
originalElementsMap,
);
};
const Position = ({
property,
element,
elementsMap,
scene,
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = rotate(
element.x,
element.y,
element.x + element.width / 2,
element.y + element.height / 2,
element.angle,
);
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
return (
<StatsDragInput
label={property === "x" ? "X" : "Y"}
elements={[element]}
dragInputCallback={handlePositionChange}
value={value}
property={property}
scene={scene}
appState={appState}
/>
);
};
export default Position;
@@ -0,0 +1,302 @@
import { useEffect, useMemo, useState, memo } from "react";
import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import type { AppState, ExcalidrawProps } from "../../types";
import { CloseIcon } from "../icons";
import { Island } from "../Island";
import { throttle } from "lodash";
import Dimension from "./Dimension";
import Angle from "./Angle";
import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups";
import MultiAngle from "./MultiAngle";
import MultiFontSize from "./MultiFontSize";
import Position from "./Position";
import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
interface StatsProps {
scene: Scene;
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}
const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState();
const sceneNonce = props.scene.getSceneNonce() || 1;
const selectedElements = props.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
return (
<StatsInner
{...props}
appState={appState}
sceneNonce={sceneNonce}
selectedElements={selectedElements}
/>
);
};
export const StatsInner = memo(
({
scene,
onClose,
renderCustomStats,
selectedElements,
appState,
sceneNonce,
}: StatsProps & {
sceneNonce: number;
selectedElements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
}) => {
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
const setAppState = useExcalidrawSetAppState();
const singleElement =
selectedElements.length === 1 ? selectedElements[0] : null;
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
}>({
width: 0,
height: 0,
});
const throttledSetSceneDimension = useMemo(
() =>
throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
const boundingBox = getCommonBounds(elements);
setSceneDimension({
width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
});
}, STATS_TIMEOUT),
[],
);
useEffect(() => {
throttledSetSceneDimension(elements);
}, [sceneNonce, elements, throttledSetSceneDimension]);
useEffect(
() => () => throttledSetSceneDimension.cancel(),
[throttledSetSceneDimension],
);
const atomicUnits = useMemo(() => {
return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]);
return (
<div className="Stats">
<Island padding={3}>
<div className="title">
<h2>{t("stats.title")}</h2>
<div className="close" onClick={onClose}>
{CloseIcon}
</div>
</div>
<Collapsible
label={<h3>{t("stats.generalStats")}</h3>}
open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels: state.stats.panels ^ STATS_PANELS.generalStats,
},
};
})
}
>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
{renderCustomStats?.(elements, appState)}
</tbody>
</table>
</Collapsible>
{selectedElements.length > 0 && (
<div
id="elementStats"
style={{
marginTop: 12,
}}
>
<Collapsible
label={<h3>{t("stats.elementProperties")}</h3>}
open={
!!(appState.stats.panels & STATS_PANELS.elementProperties)
}
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels:
state.stats.panels ^ STATS_PANELS.elementProperties,
},
};
})
}
>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
{t(`element.${singleElement.type}`)}
</div>
<div className="statsItem">
<Position
element={singleElement}
property="x"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Position
element={singleElement}
property="y"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Dimension
property="width"
element={singleElement}
scene={scene}
appState={appState}
/>
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
/>
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/>
</div>
</div>
)}
{multipleElements && (
<div className="sectionContent">
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<div>{selectedElements.length}</div>
</div>
<div className="statsItem">
<MultiPosition
property="x"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiPosition
property="y"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiAngle
property="angle"
elements={multipleElements}
scene={scene}
appState={appState}
/>
<MultiFontSize
property="fontSize"
elements={multipleElements}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
</div>
</div>
)}
</Collapsible>
</div>
)}
</Island>
</div>
);
},
(prev, next) => {
return (
prev.sceneNonce === next.sceneNonce &&
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels
);
},
);
@@ -0,0 +1,756 @@
import { fireEvent, queryByTestId } from "@testing-library/react";
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
import { getStepSizedValue } from "./utils";
import {
GlobalTestState,
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
} from "../../tests/test-utils";
import * as StaticScene from "../../renderer/staticScene";
import { vi } from "vitest";
import { reseed } from "../../random";
import { setDateTimeForTests } from "../../utils";
import { Excalidraw, mutateElement } from "../..";
import { t } from "../../i18n";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../../element/types";
import { degreeToRadian, rotate } from "../../math";
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
import React from "react";
const { h } = window;
const mouse = new Pointer("mouse");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null;
const editInput = (input: HTMLInputElement, value: string) => {
input.focus();
fireEvent.change(input, { target: { value } });
input.blur();
};
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
const testInputProperty = (
element: ExcalidrawElement,
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
label: string,
initialValue: number,
nextValue: number,
) => {
const input = getStatsProperty(label)?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(initialValue.toString());
editInput(input, String(nextValue));
if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
} else if (property === "fontSize" && isTextElement(element)) {
expect(element[property]).toBe(Number(nextValue));
} else if (property !== "fontSize") {
expect(element[property]).toBe(Number(nextValue));
}
};
describe("step sized value", () => {
it("should return edge values correctly", () => {
const steps = [10, 15, 20, 25, 30];
const values = [10, 15, 20, 25, 30];
steps.forEach((step, idx) => {
expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]);
});
});
it("step sized value lies in the middle", () => {
let stepSize = 15;
let values = [7.5, 9, 12, 14.99, 15, 22.49];
values.forEach((value) => {
expect(getStepSizedValue(value, stepSize)).toEqual(15);
});
stepSize = 10;
values = [-5, 4.99, 0, 1.23];
values.forEach((value) => {
expect(getStepSizedValue(value, stepSize)).toEqual(0);
});
});
});
describe("binding with linear elements", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(19);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
UI.clickTool("arrow");
mouse.down(5, 0);
mouse.up(300, 50);
elementStats = stats?.querySelector("#elementStats");
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
editInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
});
it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
editInput(inputX, String("254"));
expect(linear.startBinding).toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
});
// single element
describe("stats for a generic element", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
elementStats = stats?.querySelector("#elementStats");
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should open stats", () => {
expect(stats).toBeDefined();
expect(elementStats).toBeDefined();
// title
const title = elementStats?.querySelector("h3");
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
// element type
const elementType = elementStats?.querySelector(".elementType");
expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect(
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
),
).toBeDefined();
});
});
it("should be able to edit all properties for a general element", () => {
const rectangle = h.elements[0];
const initialX = rectangle.x;
const initialY = rectangle.y;
testInputProperty(rectangle, "width", "W", 200, 100);
testInputProperty(rectangle, "height", "H", 100, 200);
testInputProperty(rectangle, "x", "X", initialX, 230);
testInputProperty(rectangle, "y", "Y", initialY, 220);
testInputProperty(rectangle, "angle", "A", 0, 45);
});
it("should keep only two decimal places", () => {
const rectangle = h.elements[0];
const rectangleId = rectangle.id;
const input = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(rectangle.width.toString());
editInput(input, "123.123");
expect(h.elements.length).toBe(1);
expect(rectangle.id).toBe(rectangleId);
expect(input.value).toBe("123.12");
expect(rectangle.width).toBe(123.12);
editInput(input, "88.98766");
expect(input.value).toBe("88.99");
expect(rectangle.width).toBe(88.99);
});
it("should update input x and y when angle is changed", () => {
const rectangle = h.elements[0];
const [cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
const xInput = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
const yInput = getStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(xInput.value).toBe(topLeftX.toString());
expect(yInput.value).toBe(topLeftY.toString());
testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
});
it("should fix top left corner when width or height is changed", () => {
const rectangle = h.elements[0];
testInputProperty(rectangle, "angle", "A", 0, 45);
let [cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
[cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
let [currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
testInputProperty(rectangle, "height", "H", rectangle.height, 400);
[cx, cy] = [
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
[currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
});
});
describe("stats for a non-generic element", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("text element", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
editor.blur();
const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text);
elementStats = stats?.querySelector("#elementStats");
// can change font size
const input = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(text.fontSize.toString());
editInput(input, "36");
expect(text.fontSize).toBe(36);
// cannot change width or height
const width = getStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined();
const height = getStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined();
// min font size is 4
editInput(input, "0");
expect(text.fontSize).not.toBe(0);
expect(text.fontSize).toBe(4);
});
it("frame element", () => {
const frame = API.createElement({
id: "id0",
type: "frame",
x: 150,
width: 150,
});
h.elements = [frame];
h.setState({
selectedElementIds: {
[frame.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
expect(elementStats).toBeDefined();
// cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined();
// can change width or height
testInputProperty(frame, "width", "W", frame.width, 250);
testInputProperty(frame, "height", "H", frame.height, 500);
});
it("image element", () => {
const image = API.createElement({ type: "image", width: 200, height: 100 });
h.elements = [image];
mouse.clickOn(image);
h.setState({
selectedElementIds: {
[image.id]: true,
},
});
elementStats = stats?.querySelector("#elementStats");
expect(elementStats).toBeDefined();
const widthToHeight = image.width / image.height;
// when width or height is changed, the aspect ratio is preserved
testInputProperty(image, "width", "W", image.width, 400);
expect(image.width).toBe(400);
expect(image.width / image.height).toBe(widthToHeight);
testInputProperty(image, "height", "H", image.height, 80);
expect(image.height).toBe(80);
expect(image.width / image.height).toBe(widthToHeight);
});
it("should display fontSize for bound text", () => {
const container = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
const text = API.createElement({
type: "text",
width: 200,
height: 100,
containerId: container.id,
fontSize: 20,
});
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
h.elements = [container, text];
API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
editInput(fontSize, "40");
expect(text.fontSize).toBe(40);
});
});
// multiple elements
describe("stats for multiple elements", () => {
beforeEach(async () => {
mouse.reset();
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should display MIXED for elements with different values", () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
UI.clickTool("ellipse");
mouse.down(50, 50);
mouse.up(100, 100);
UI.clickTool("diamond");
mouse.down(-100, -100);
mouse.up(125, 145);
h.setState({
selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as Record<string, true>),
});
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width?.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height?.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle.value).toBe("0");
editInput(width, "250");
h.elements.forEach((el) => {
expect(el.width).toBe(250);
});
editInput(height, "450");
h.elements.forEach((el) => {
expect(el.height).toBe(450);
});
});
it("should display a property when one of the elements is editable for that property", async () => {
// text, rectangle, frame
UI.clickTool("text");
mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
editor.blur();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
const frame = API.createElement({
type: "frame",
x: 150,
width: 150,
});
h.elements = [...h.elements, frame];
const text = h.elements.find((el) => el.type === "text");
const rectangle = h.elements.find((el) => el.type === "rectangle");
h.setState({
selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as Record<string, true>),
});
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle).toBeDefined();
expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
// changing width does not affect text
editInput(width, "200");
expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200);
editInput(angle, "40");
const angleInRadian = degreeToRadian(40);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
expect(frame.angle).toBe(0);
});
it("should treat groups as single units", () => {
const createAndSelectGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
h.app.actionManager.executeAction(actionGroup);
};
createAndSelectGroup();
const elementsInGroup = h.elements.filter((el) => isInGroup(el));
let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
elementStats = stats?.querySelector("#elementStats");
const x = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(x).toBeDefined();
expect(Number(x.value)).toBe(x1);
editInput(x, "300");
expect(h.elements[0].x).toBe(300);
expect(h.elements[1].x).toBe(400);
expect(x.value).toBe("300");
const y = getStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(y).toBeDefined();
expect(Number(y.value)).toBe(y1);
editInput(y, "200");
expect(h.elements[0].y).toBe(200);
expect(h.elements[1].y).toBe(300);
expect(y.value).toBe("200");
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(Number(height.value)).toBe(200);
editInput(width, "400");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
let newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(400, 4);
editInput(width, "300");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(300, 4);
editInput(height, "500");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
const newGroupHeight = y2 - y1;
expect(newGroupHeight).toBeCloseTo(500, 4);
});
});
@@ -0,0 +1,301 @@
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../../element/typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import {
getSelectedGroupIds,
getElementsInGroup,
isInGroup,
} from "../../groups";
import { rotate } from "../../math";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
| "y"
| "width"
| "height"
| "angle"
| "fontSize";
export const SMALLEST_DELTA = 0.01;
export const isPropertyEditable = (
element: ExcalidrawElement,
property: keyof ExcalidrawElement,
) => {
if (property === "height" && isTextElement(element)) {
return false;
}
if (property === "width" && isTextElement(element)) {
return false;
}
if (property === "angle" && isFrameLikeElement(element)) {
return false;
}
return true;
};
export const getStepSizedValue = (value: number, stepSize: number) => {
const v = value + stepSize / 2;
return v - (v % stepSize);
};
export type AtomicUnit = Record<string, true>;
export const getElementsInAtomicUnit = (
atomicUnit: AtomicUnit,
elementsMap: ElementsMap,
originalElementsMap?: ElementsMap,
) => {
return Object.keys(atomicUnit)
.map((id) => ({
original: (originalElementsMap ?? elementsMap).get(id),
latest: elementsMap.get(id),
}))
.filter((el) => el.original !== undefined && el.latest !== undefined) as {
original: NonDeletedExcalidrawElement;
latest: NonDeletedExcalidrawElement;
}[];
};
export const newOrigin = (
x1: number,
y1: number,
w1: number,
h1: number,
w2: number,
h2: number,
angle: number,
) => {
/**
* The formula below is the result of solving
* rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
* where rotate is the function defined in math.ts
*
* This is so that the new origin (x2, y2),
* when rotated against the new center (cx2, cy2),
* coincides with (x1, y1) rotated against (cx1, cy1)
*
* The reason for doing this computation is so the element's top left corner
* on the canvas remains fixed after any changes in its dimension.
*/
return {
x:
x1 +
(w1 - w2) / 2 +
((w2 - w1) / 2) * Math.cos(angle) +
((h1 - h2) / 2) * Math.sin(angle),
y:
y1 +
(h1 - h2) / 2 +
((w2 - w1) / 2) * Math.sin(angle) +
((h2 - h1) / 2) * Math.cos(angle),
};
};
export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
mutateElement(
latestElement,
{
...newOrigin(
latestElement.x,
latestElement.y,
latestElement.width,
latestElement.height,
nextWidth,
nextHeight,
latestElement.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
}
}
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
};
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) {
return;
}
const [cx, cy] = [
originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
originalElement.x,
originalElement.y,
cx,
cy,
originalElement.angle,
);
const changeInX = newTopLeftX - topLeftX;
const changeInY = newTopLeftY - topLeftY;
const [x, y] = rotate(
newTopLeftX,
newTopLeftY,
cx + changeInX,
cy + changeInY,
-originalElement.angle,
);
mutateElement(
latestElement,
{
x,
y,
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(
originalElement,
originalElementsMap,
);
if (boundTextElement) {
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
latestBoundTextElement &&
mutateElement(
latestBoundTextElement,
{
x: boundTextElement.x + changeInX,
y: boundTextElement.y + changeInY,
},
shouldInformMutation,
);
}
};
export const getAtomicUnits = (
targetElements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedGroupIds = getSelectedGroupIds(appState);
const _atomicUnits = selectedGroupIds.map((gid) => {
return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as AtomicUnit);
});
targetElements
.filter((el) => !isInGroup(el))
.forEach((el) => {
_atomicUnits.push({
[el.id]: true,
});
});
return _atomicUnits;
};
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
} else {
updateBoundElements(latestElement, elementsMap, options);
}
};
@@ -1,10 +1,6 @@
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_SIZE,
EDITOR_LS_KEYS,
} from "../../constants";
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import type { AppClassProperties, BinaryFiles } from "../../types";
@@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps {
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
options: MermaidOptions,
config?: MermaidConfig,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
@@ -78,15 +74,10 @@ export const convertMermaidToExcalidraw = async ({
let ret;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
fontSize: DEFAULT_FONT_SIZE,
});
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: any) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
);
}
const { elements, files } = ret;
@@ -9,7 +9,10 @@ import type {
RenderableElementsMap,
RenderInteractiveSceneCallback,
} from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import type {
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
@@ -19,6 +22,7 @@ type InteractiveCanvasProps = {
elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap;
sceneNonce: number | undefined;
selectionNonce: number | undefined;
scale: number;
@@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
elementsMap: props.elementsMap,
visibleElements: props.visibleElements,
selectedElements: props.selectedElements,
allElementsMap: props.allElementsMap,
scale: window.devicePixelRatio,
appState: props.appState,
renderConfig: {
@@ -197,6 +202,7 @@ const getRelevantAppStateProps = (
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingElement: appState.editingElement,
});
const areEqual = (
+28
View File
@@ -1573,6 +1573,18 @@ export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const angleIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M21 19h-18l9 -15" />
<path d="M20.615 15.171h.015" />
<path d="M19.515 11.771h.015" />
<path d="M17.715 8.671h.015" />
<path d="M15.415 5.971h.015" />
</g>,
tablerIconProps,
);
export const publishIcon = createIcon(
<path
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
@@ -2061,3 +2073,19 @@ export const lineEditorIcon = createIcon(
</g>,
tablerIconProps,
);
export const collapseDownIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 9l6 6l6 -6" />
</g>,
tablerIconProps,
);
export const collapseUpIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 15l6 -6l6 6" />
</g>,
tablerIconProps,
);
+10 -1
View File
@@ -25,6 +25,11 @@ export const supportsResizeObserver =
export const APP_NAME = "Excalidraw";
// distance when creating text before it's considered `autoResize: false`
// we're using higher threshold so that clicks that end up being drags
// don't unintentionally create text elements that are wrapped to a few chars
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
@@ -269,7 +274,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
@@ -400,3 +405,7 @@ export const EDITOR_LS_KEYS = {
* where filename is optional and we can't retrieve name from app state
*/
export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
+6
View File
@@ -22,6 +22,12 @@
--sat: env(safe-area-inset-top);
}
body.excalidraw-cursor-resize,
body.excalidraw-cursor-resize a:hover,
body.excalidraw-cursor-resize * {
cursor: ew-resize;
}
.excalidraw {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
+17 -1
View File
@@ -123,10 +123,26 @@ export const loadSceneOrLibraryFromBlob = async (
fileHandle?: FileSystemHandle | null,
) => {
const contents = await parseFileContents(blob);
let data;
// assume Obsidian excalidraw plugin file
if (blob.name?.endsWith(".excalidraw.md")) {
if (contents.indexOf("```compressed-json") > -1) {
let str = contents.slice(
contents.indexOf("```compressed-json") + '"```compressed-json'.length,
);
str = str.slice(0, str.indexOf("```"));
str = str.replace(/\n/g, "").replace(/\r/g, "");
const LZString = await import("lz-string");
data = JSON.parse(LZString.decompressFromBase64(str));
}
}
try {
try {
data = JSON.parse(contents);
data = data || JSON.parse(contents);
} catch (error: any) {
if (isSupportedImageFile(blob)) {
throw new ImageSceneDataError(
+26 -1
View File
@@ -1,6 +1,7 @@
import type {
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
@@ -21,7 +22,12 @@ import {
isInvisiblySmallElement,
refreshTextDimensions,
} from "../element";
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
import {
isArrowElement,
isLinearElement,
isTextElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import { randomId } from "../random";
import {
DEFAULT_FONT_FAMILY,
@@ -45,6 +51,7 @@ import {
} from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
type RestoredAppState = Omit<
AppState,
@@ -270,6 +277,7 @@ const restoreElement = (
points,
x,
y,
...getSizeFromPoints(points),
});
}
@@ -458,6 +466,23 @@ export const restoreElements = (
),
);
}
if (isLinearElement(element)) {
if (
element.startBinding &&
(!restoredElementsMap.has(element.startBinding.elementId) ||
!isArrowElement(element))
) {
(element as Mutable<ExcalidrawLinearElement>).startBinding = null;
}
if (
element.endBinding &&
(!restoredElementsMap.has(element.endBinding.elementId) ||
!isArrowElement(element))
) {
(element as Mutable<ExcalidrawLinearElement>).endBinding = null;
}
}
}
return restoredElements;
+71 -51
View File
@@ -25,7 +25,7 @@ import type {
} from "./types";
import { getElementAbsoluteCoords } from "./bounds";
import type { AppClassProperties, AppState, Point } from "../types";
import type { AppState, Point } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import {
@@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -179,19 +180,19 @@ const bindOrUnbindLinearElementEdge = (
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawElement> | null => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(
elementId,
) as NonDeleted<ExcalidrawBindableElement>;
if (bindingBorderTest(element, coors, app)) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
) {
return element;
}
}
@@ -201,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement,
edge as "start" | "end",
app,
elementsMap,
),
);
@@ -215,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>,
isBindingEnabled: boolean,
draggingPoints: readonly number[],
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0;
const endIdx = selectedElement.points.length - 1;
@@ -223,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged
? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "start", app)
? getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
)
: null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "start", app);
getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
);
const end = endDragged
? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "end", app)
? getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
)
: null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "end", app);
getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
return [start, end];
};
const getBindingStrategyForDraggingArrowOrJoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
isBindingEnabled: boolean,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement,
app,
elementsMap,
);
const start = startIsClose
? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "start", app)
? getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
)
: null
: null;
const end = endIsClose
? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "end", app)
? getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
)
: null
: null;
@@ -262,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
): void => {
@@ -273,27 +294,22 @@ export const bindOrUnbindLinearElements = (
selectedElement,
isBindingEnabled,
draggingPoints ?? [],
app,
elementsMap,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
selectedElement,
app,
elementsMap,
isBindingEnabled,
);
bindOrUnbindLinearElement(
selectedElement,
start,
end,
app.scene.getNonDeletedElementsMap(),
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
});
};
export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[],
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
@@ -304,7 +320,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements
.filter(isLinearElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, app),
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -326,17 +342,20 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
pointerCoords: { x: number; y: number },
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): void => {
if (appState.startBoundElement != null) {
bindLinearElement(
linearElement,
appState.startBoundElement,
"start",
app.scene.getNonDeletedElementsMap(),
elementsMap,
);
}
const hoveredElement = getHoveredElementForBinding(pointerCoords, app);
const hoveredElement = getHoveredElementForBinding(
pointerCoords,
elementsMap,
);
if (
hoveredElement != null &&
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
@@ -345,12 +364,7 @@ export const maybeBindLinearElement = (
"end",
)
) {
bindLinearElement(
linearElement,
hoveredElement,
"end",
app.scene.getNonDeletedElementsMap(),
);
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
}
};
@@ -360,6 +374,9 @@ export const bindLinearElement = (
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
): void => {
if (!isArrowElement(linearElement)) {
return;
}
mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
elementId: hoveredElement.id,
@@ -431,13 +448,13 @@ export const getHoveredElementForBinding = (
x: number;
y: number;
},
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
app.scene.getNonDeletedElements(),
[...elementsMap].map(([_, value]) => value),
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords, app),
bindingBorderTest(element, pointerCoords, elementsMap),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -661,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = (
const getElligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding(
getLinearElementEdgeCoors(
linearElement,
startOrEnd,
app.scene.getNonDeletedElementsMap(),
),
app,
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elementsMap,
);
};
@@ -706,6 +719,9 @@ export const fixBindingsAfterDuplication = (
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
const duplicateIdToOldId = new Map(
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
);
oldElements.forEach((oldElement) => {
const { boundElements } = oldElement;
if (boundElements != null && boundElements.length > 0) {
@@ -755,7 +771,11 @@ export const fixBindingsAfterDuplication = (
sceneElements
.filter(({ id }) => allBindableElementIds.has(id))
.forEach((bindableElement) => {
const { boundElements } = bindableElement;
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
const { boundElements } = sceneElements.find(
({ id }) => id === oldElementId,
)!;
if (boundElements != null && boundElements.length > 0) {
mutateElement(bindableElement, {
boundElements: boundElements.map((boundElement) =>
@@ -826,10 +846,10 @@ const newBoundElements = (
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number },
app: AppClassProperties,
elementsMap: ElementsMap,
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height);
const shape = app.getElementShape(element);
const shape = getElementShape(element, elementsMap);
return isPointOnShape([x, y], shape, threshold);
};
+39 -3
View File
@@ -4,11 +4,17 @@ import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types";
import type { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement";
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import { isArrowElement, isFrameLikeElement } from "./typeChecks";
import {
isArrowElement,
isFrameLikeElement,
isTextElement,
} from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -140,6 +146,7 @@ export const dragNewElement = (
height: number,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
zoom: NormalizedZoomValue,
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
@@ -185,12 +192,41 @@ export const dragNewElement = (
newY = originY - height / 2;
}
let textAutoResize = null;
// NOTE this should apply only to creating text elements, not existing
// (once we rewrite appState.draggingElement to actually mean dragging
// elements)
if (isTextElement(draggingElement)) {
height = draggingElement.height;
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: draggingElement.fontSize,
fontFamily: draggingElement.fontFamily,
}),
draggingElement.lineHeight,
);
width = Math.max(width, minWidth);
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
textAutoResize = {
autoResize: false,
};
}
newY = originY;
if (shouldResizeFromCenter) {
newX = originX - width / 2;
}
}
if (width !== 0 && height !== 0) {
mutateElement(draggingElement, {
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
height,
...textAutoResize,
});
}
};
@@ -381,7 +381,7 @@ export class LinearElementEditor {
elementsMap,
),
),
app,
elementsMap,
)
: null;
@@ -715,7 +715,10 @@ export class LinearElementEditor {
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(scenePointer, app),
endBindingElement: getHoveredElementForBinding(
scenePointer,
elementsMap,
),
};
ret.didAddPoint = true;
@@ -1165,7 +1168,7 @@ export class LinearElementEditor {
const nextPoints = points.map((point, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx);
if (selectedPointData) {
if (selectedOriginPoint) {
if (selectedPointData.index === 0) {
return point;
}
@@ -1174,7 +1177,10 @@ export class LinearElementEditor {
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
return [point[0] + deltaX, point[1] + deltaY] as const;
return [
point[0] + deltaX - offsetX,
point[1] + deltaY - offsetY,
] as const;
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
+1 -3
View File
@@ -107,8 +107,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
/** pass `true` to always regenerate */
force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
@@ -125,7 +123,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
}
}
if (!didChange && !force) {
if (!didChange) {
return element;
}
+11 -10
View File
@@ -1,8 +1,4 @@
import {
BOUND_TEXT_PADDING,
MIN_FONT_SIZE,
SHIFT_LOCKING_ANGLE,
} from "../constants";
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
@@ -51,7 +47,7 @@ import {
getApproxMinLineHeight,
wrapText,
measureText,
getMinCharWidth,
getMinTextElementWidth,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
@@ -182,7 +178,7 @@ const rotateSingleElement = (
}
};
const rescalePointsInElement = (
export const rescalePointsInElement = (
element: NonDeletedExcalidrawElement,
width: number,
height: number,
@@ -199,7 +195,7 @@ const rescalePointsInElement = (
}
: {};
const measureFontSizeFromWidth = (
export const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
nextWidth: number,
@@ -352,8 +348,13 @@ const resizeSingleTextElement = (
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth =
getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2;
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: element.fontSize,
fontFamily: element.fontFamily,
}),
element.lineHeight,
);
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
@@ -938,3 +938,10 @@ export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
@@ -576,7 +576,7 @@ describe("textWysiwyg", () => {
it("text should never go beyond max width", async () => {
UI.clickTool("text");
mouse.clickAt(750, 300);
mouse.click(0, 0);
textarea = await getTextEditor(textEditorSelector, true);
updateTextEditor(
+35 -8
View File
@@ -77,6 +77,7 @@ export const textWysiwyg = ({
canvas,
excalidrawContainer,
app,
autoSelect = true,
}: {
id: ExcalidrawElement["id"];
/**
@@ -92,6 +93,7 @@ export const textWysiwyg = ({
canvas: HTMLCanvasElement;
excalidrawContainer: HTMLDivElement | null;
app: App;
autoSelect?: boolean;
}) => {
const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement,
@@ -491,6 +493,11 @@ export const textWysiwyg = ({
// so that we don't need to create separate a callback for event handlers
let submittedViaKeyboard = false;
const handleSubmit = () => {
// prevent double submit
if (isDestroyed) {
return;
}
isDestroyed = true;
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
// wysiwyg on update
@@ -544,10 +551,6 @@ export const textWysiwyg = ({
};
const cleanup = () => {
if (isDestroyed) {
return;
}
isDestroyed = true;
// remove events to ensure they don't late-fire
editable.onblur = null;
editable.oninput = null;
@@ -639,6 +642,22 @@ export const textWysiwyg = ({
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
} else if (
event.target instanceof HTMLElement &&
!event.target.contains(editable) &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do
!isTestEnv()
) {
// On mobile, blur event doesn't seem to always fire correctly,
// so we want to also submit on pointerdown outside the wysiwyg.
// Done in the next frame to prevent pointerdown from creating a new text
// immediately (if tools locked) so that users on mobile have chance
// to submit first (to hide virtual keyboard).
// Note: revisit if we want to differ this behavior on Desktop
requestAnimationFrame(() => {
handleSubmit();
});
}
};
@@ -657,9 +676,11 @@ export const textWysiwyg = ({
let isDestroyed = false;
// select on init (focusing is done separately inside the bindBlurEvent()
// because we need it to happen *after* the blur event from `pointerdown`)
editable.select();
if (autoSelect) {
// select on init (focusing is done separately inside the bindBlurEvent()
// because we need it to happen *after* the blur event from `pointerdown`)
editable.select();
}
bindBlurEvent();
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
@@ -674,7 +695,13 @@ export const textWysiwyg = ({
window.addEventListener("resize", updateWysiwygStyle);
}
window.addEventListener("pointerdown", onPointerDown);
editable.onpointerdown = (event) => event.stopPropagation();
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
// triggered the wysiwyg
requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true });
});
window.addEventListener("wheel", stopEvent, {
passive: false,
capture: true,
+1 -1
View File
@@ -132,7 +132,7 @@ export const isBindingElementType = (
};
export const isBindableElement = (
element: ExcalidrawElement | null,
element: ExcalidrawElement | null | undefined,
includeLocked = true,
): element is ExcalidrawBindableElement => {
return (
+3 -1
View File
@@ -373,7 +373,9 @@ export const getNonDeletedGroupIds = (elements: ElementsMap) => {
return nonDeletedGroupIds;
};
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
export const elementsAreInSameGroup = (
elements: readonly ExcalidrawElement[],
) => {
const allGroups = elements.flatMap((element) => element.groupIds);
const groupCount = new Map<string, number>();
let maxGroup = 0;
+20 -1
View File
@@ -270,6 +270,22 @@
"mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings"
},
"element": {
"rectangle": "Rectangle",
"diamond": "Diamond",
"ellipse": "Ellipse",
"arrow": "Arrow",
"line": "Line",
"freedraw": "Freedraw",
"text": "Text",
"image": "Image",
"group": "Group",
"frame": "Frame",
"magicframe": "Wireframe to code",
"embeddable": "Web Embed",
"selection": "Selection",
"iframe": "IFrame"
},
"headings": {
"canvasActions": "Canvas actions",
"selectedShapeActions": "Selected shape actions",
@@ -443,7 +459,10 @@
"scene": "Scene",
"selected": "Selected",
"storage": "Storage",
"title": "Stats for nerds",
"fullTitle": "Stats & Element properties",
"title": "Stats",
"generalStats": "General stats",
"elementProperties": "Element properties",
"total": "Total",
"version": "Version",
"versionCopy": "Click to copy",
+8
View File
@@ -475,6 +475,14 @@ export const isRightAngle = (angle: number) => {
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
};
export const radianToDegree = (r: number) => {
return (r * 180) / Math.PI;
};
export const degreeToRadian = (d: number) => {
return (d / 180) * Math.PI;
};
// Given two ranges, return if the two ranges overlap with each other
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
export const rangesOverlap = (
+32
View File
@@ -0,0 +1,32 @@
/** heuristically checks whether the text may be a mermaid diagram definition */
export const isMaybeMermaidDefinition = (text: string) => {
const chartTypes = [
"flowchart",
"sequenceDiagram",
"classDiagram",
"stateDiagram",
"stateDiagram-v2",
"erDiagram",
"journey",
"gantt",
"pie",
"quadrantChart",
"requirementDiagram",
"gitGraph",
"C4Context",
"mindmap",
"timeline",
"zenuml",
"sankey",
"xychart",
"block",
];
const re = new RegExp(
`^(?:%%{.*?}%%[\\s\\n]*)?\\b${chartTypes
.map((x) => `${x}(-beta)?`)
.join("|")}\\b`,
);
return re.test(text.trim());
};
+2 -1
View File
@@ -58,7 +58,7 @@
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.0.0",
"@excalidraw/mermaid-to-excalidraw": "1.1.0",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
@@ -72,6 +72,7 @@
"image-blob-reduce": "3.0.1",
"jotai": "1.13.1",
"lodash.throttle": "4.1.1",
"lz-string": "1.5.0",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "1.0.11",
@@ -47,13 +47,18 @@ import {
getNormalizedCanvasDimensions,
} from "./helpers";
import oc from "open-color";
import { isFrameLikeElement, isLinearElement } from "../element/typeChecks";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
GroupId,
NonDeleted,
} from "../element/types";
@@ -303,7 +308,6 @@ const renderSelectionBorder = (
cy: number;
activeEmbeddable: boolean;
},
padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2,
) => {
const {
angle,
@@ -320,6 +324,8 @@ const renderSelectionBorder = (
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
const linePadding = padding / appState.zoom.value;
const lineWidth = 8 / appState.zoom.value;
const spaceWidth = 4 / appState.zoom.value;
@@ -570,11 +576,34 @@ const renderTransformHandles = (
});
};
const renderTextBox = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
const width = text.width + padding * 2;
const height = text.height + padding * 2;
const cx = text.x + width / 2;
const cy = text.y + height / 2;
const shiftX = -(width / 2 + padding);
const shiftY = -(height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor;
context.strokeRect(shiftX, shiftY, width, height);
context.restore();
};
const _renderInteractiveScene = ({
canvas,
elementsMap,
visibleElements,
selectedElements,
allElementsMap,
scale,
appState,
renderConfig,
@@ -626,12 +655,31 @@ const _renderInteractiveScene = ({
// Paint selection element
if (appState.selectionElement) {
try {
renderSelectionElement(appState.selectionElement, context, appState);
renderSelectionElement(
appState.selectionElement,
context,
appState,
renderConfig.selectionColor,
);
} catch (error: any) {
console.error(error);
}
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
const textElement = allElementsMap.get(appState.editingElement.id) as
| ExcalidrawTextElement
| undefined;
if (textElement && !textElement.autoResize) {
renderTextBox(
textElement,
context,
appState,
renderConfig.selectionColor,
);
}
}
if (appState.isBindingEnabled) {
appState.suggestedBindings
.filter((binding) => binding != null)
@@ -810,7 +858,12 @@ const _renderInteractiveScene = ({
"mouse", // when we render we don't know which pointer type so use mouse,
getOmitSidesForDevice(device),
);
if (!appState.viewModeEnabled && showBoundingBox) {
if (
!appState.viewModeEnabled &&
showBoundingBox &&
// do not show transform handles when text is being edited
!isTextElement(appState.editingElement)
) {
renderTransformHandles(
context,
renderConfig,
+5 -12
View File
@@ -24,6 +24,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
import type {
StaticCanvasRenderConfig,
RenderableElementsMap,
InteractiveCanvasRenderConfig,
} from "../scene/types";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
@@ -89,7 +90,7 @@ const shouldResetImageFilter = (
};
const getCanvasPadding = (element: ExcalidrawElement) =>
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
element.type === "freedraw" ? element.strokeWidth * 12 : 200;
export const getRenderOpacity = (
element: ExcalidrawElement,
@@ -470,16 +471,7 @@ const drawElementFromCanvas = (
const element = elementWithCanvas.element;
const padding = getCanvasPadding(element);
const zoom = elementWithCanvas.scale;
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
// Free draw elements will otherwise "shuffle" as the min x and y change
if (isFreeDrawElement(element)) {
x1 = Math.floor(x1);
x2 = Math.ceil(x2);
y1 = Math.floor(y1);
y2 = Math.ceil(y2);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
@@ -618,6 +610,7 @@ export const renderSelectionElement = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
@@ -631,7 +624,7 @@ export const renderSelectionElement = (
context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = " rgb(105, 101, 219)";
context.strokeStyle = selectionColor;
context.strokeRect(offset, offset, element.width, element.height);
context.restore();
+9 -5
View File
@@ -1,5 +1,5 @@
import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement";
import { getContainerElement } from "../element/textElement";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -46,14 +46,18 @@ export class Fonts {
let didUpdate = false;
this.scene.mapElements((element) => {
const elementsMap = this.scene.getNonDeletedElementsMap();
for (const element of this.scene.getNonDeletedElements()) {
if (isTextElement(element)) {
didUpdate = true;
ShapeCache.delete(element);
return newElementWith(element, {}, true);
const container = getContainerElement(element, elementsMap);
if (container) {
ShapeCache.delete(container);
}
}
return element;
});
}
if (didUpdate) {
this.scene.triggerUpdate();
+3
View File
@@ -105,6 +105,9 @@ class Scene {
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
+2 -6
View File
@@ -2,7 +2,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Drawable } from "roughjs/bin/core";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
@@ -55,7 +54,7 @@ export type InteractiveCanvasRenderConfig = {
remotePointerUserStates: Map<SocketId, UserIdleState>;
remotePointerUsernames: Map<SocketId, string>;
remotePointerButton: Map<SocketId, string | undefined>;
selectionColor?: string;
selectionColor: string;
// extra options passed to the renderer
// ---------------------------------------------------------------------------
renderScrollbars?: boolean;
@@ -83,6 +82,7 @@ export type InteractiveSceneRenderConfig = {
elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
appState: InteractiveCanvasAppState;
renderConfig: InteractiveCanvasRenderConfig;
@@ -95,10 +95,6 @@ export type SceneScroll = {
scrollY: number;
};
export interface Scene {
elements: ExcalidrawTextElement[];
}
export type ExportType =
| "png"
| "clipboard"
+62
View File
@@ -1,3 +1,11 @@
import {
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
type GeometricShape,
} from "../utils/geometry/shape";
import {
ArrowIcon,
DiamondIcon,
@@ -10,7 +18,11 @@ import {
SelectionIcon,
TextIcon,
} from "./components/icons";
import { getElementAbsoluteCoords } from "./element";
import { shouldTestInside } from "./element/collision";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { KEYS } from "./keys";
import { ShapeCache } from "./scene/ShapeCache";
export const SHAPES = [
{
@@ -97,3 +109,53 @@ export const findShapeByKey = (key: string) => {
});
return shape?.value || null;
};
/**
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
export const getElementShape = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape(
element,
roughShape,
[element.x, element.y],
element.angle,
[cx, cy],
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
}
}
};
+2 -1
View File
@@ -1375,6 +1375,7 @@ export const isActiveToolNonLinearSnappable = (
activeToolType === TOOL_TYPE.diamond ||
activeToolType === TOOL_TYPE.frame ||
activeToolType === TOOL_TYPE.magicframe ||
activeToolType === TOOL_TYPE.image
activeToolType === TOOL_TYPE.image ||
activeToolType === TOOL_TYPE.text
);
};
@@ -1,28 +1,12 @@
import { act, render, waitFor } from "./test-utils";
import { Excalidraw } from "../index";
import React from "react";
import { expect, vi } from "vitest";
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
import { expect } from "vitest";
import { getTextEditor, updateTextEditor } from "./queries/dom";
import { mockMermaidToExcalidraw } from "./helpers/mocks";
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
const module = (await importActual()) as any;
return {
__esModule: true,
...module,
};
});
const parseMermaidToExcalidrawSpy = vi.spyOn(
MermaidToExcalidraw,
"parseMermaidToExcalidraw",
);
parseMermaidToExcalidrawSpy.mockImplementation(
async (
definition: string,
options?: MermaidToExcalidraw.MermaidOptions | undefined,
) => {
mockMermaidToExcalidraw({
mockRef: true,
parseMermaidToExcalidraw: async (definition) => {
const firstLine = definition.split("\n")[0];
return new Promise((resolve, reject) => {
if (firstLine === "flowchart TD") {
@@ -88,12 +72,6 @@ parseMermaidToExcalidrawSpy.mockImplementation(
}
});
},
);
vi.spyOn(React, "useRef").mockReturnValue({
current: {
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
},
});
describe("Test <MermaidToExcalidraw/>", () => {
@@ -874,10 +874,13 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1066,10 +1069,13 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": {
@@ -1274,10 +1280,13 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1597,10 +1606,13 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1920,10 +1932,13 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": {
@@ -2126,10 +2141,13 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2360,10 +2378,13 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2658,10 +2679,13 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3014,10 +3038,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": {
@@ -3481,10 +3508,13 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3796,10 +3826,13 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4114,10 +4147,13 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5292,10 +5328,13 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -6413,10 +6452,13 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7243,7 +7285,12 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
</g>
</svg>,
"keyTest": [Function],
"label": "stats.title",
"keywords": [
"edit",
"attributes",
"customize",
],
"label": "stats.fullTitle",
"name": "stats",
"paletteName": "Toggle stats",
"perform": [Function],
@@ -7331,10 +7378,13 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8234,10 +8284,13 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9123,10 +9176,13 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -84,10 +84,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -662,10 +665,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1155,10 +1161,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1498,10 +1507,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1841,10 +1853,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2099,10 +2114,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2526,10 +2544,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2817,10 +2838,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3093,10 +3117,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3379,10 +3406,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3657,10 +3687,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3884,10 +3917,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4135,10 +4171,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4400,10 +4439,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4623,10 +4665,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4846,10 +4891,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5067,10 +5115,13 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5288,10 +5339,13 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5538,10 +5592,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5861,10 +5918,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -6281,10 +6341,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -6657,10 +6720,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -6957,10 +7023,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7245,10 +7314,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7466,10 +7538,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7813,10 +7888,13 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8166,10 +8244,13 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8556,10 +8637,13 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8837,10 +8921,13 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9094,10 +9181,13 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9350,10 +9440,13 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9574,10 +9667,13 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9866,10 +9962,13 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10194,10 +10293,13 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10423,10 +10525,13 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10667,10 +10772,13 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10900,10 +11008,13 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11131,10 +11242,13 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11527,10 +11641,13 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11765,10 +11882,13 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11998,10 +12118,13 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -12231,10 +12354,13 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -12470,10 +12596,13 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -12795,10 +12924,13 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -12961,10 +13093,13 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13239,10 +13374,13 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13499,10 +13637,13 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13765,10 +13906,13 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13919,10 +14063,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -14601,10 +14748,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -15207,10 +15357,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -15811,10 +15964,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -16511,10 +16667,13 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -17245,10 +17404,13 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -17713,10 +17875,13 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -18222,10 +18387,13 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -18672,10 +18840,13 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -92,10 +92,13 @@ exports[`given element A and group of elements B and given both are selected whe
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -498,10 +501,13 @@ exports[`given element A and group of elements B and given both are selected whe
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -884,10 +890,13 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1418,10 +1427,13 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1616,10 +1628,13 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -1977,10 +1992,13 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2204,10 +2222,13 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2375,10 +2396,13 @@ exports[`regression tests > can drag element that covers another element, while
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2682,10 +2706,13 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -2919,10 +2946,13 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3151,10 +3181,13 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3370,10 +3403,13 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3616,10 +3652,13 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -3915,10 +3954,13 @@ exports[`regression tests > deleting last but one element in editing group shoul
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4377,10 +4419,13 @@ exports[`regression tests > deselects group of selected elements on pointer down
},
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4649,10 +4694,13 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -4950,10 +4998,13 @@ exports[`regression tests > deselects selected element on pointer down when poin
},
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5119,10 +5170,13 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5307,10 +5361,13 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5682,10 +5739,13 @@ exports[`regression tests > drags selected elements from point inside common bou
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -5955,10 +6015,13 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -6755,10 +6818,13 @@ exports[`regression tests > given a group of selected elements with an element t
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7074,10 +7140,13 @@ exports[`regression tests > given a selected element A and a not selected elemen
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7338,10 +7407,13 @@ exports[`regression tests > given selected element A with lower z-index than uns
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7561,10 +7633,13 @@ exports[`regression tests > given selected element A with lower z-index than uns
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7785,10 +7860,13 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -7954,10 +8032,13 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8123,10 +8204,13 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8315,10 +8399,13 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8524,10 +8611,13 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8708,10 +8798,13 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -8916,10 +9009,13 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9102,10 +9198,13 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9294,10 +9393,13 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9480,10 +9582,13 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9647,10 +9752,13 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -9832,10 +9940,13 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10009,10 +10120,13 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10506,10 +10620,13 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10768,10 +10885,13 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"selectionElement": null,
"shouldCacheIgnoreZoom": true,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -10885,10 +11005,13 @@ exports[`regression tests > shift click on selected element should deselect it o
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11077,10 +11200,13 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11379,10 +11505,13 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -11784,10 +11913,13 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -12377,10 +12509,13 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -12496,10 +12631,13 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13130,10 +13268,13 @@ exports[`regression tests > switches from group of selected elements to another
},
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13486,10 +13627,13 @@ exports[`regression tests > switches selected element on pointer down > [end of
},
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13706,10 +13850,13 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"selectionElement": null,
"shouldCacheIgnoreZoom": true,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -13823,10 +13970,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -14188,10 +14338,13 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
@@ -14306,10 +14459,13 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
+46 -1
View File
@@ -1,5 +1,5 @@
import { fireEvent, render } from "./test-utils";
import { Excalidraw } from "../index";
import { Excalidraw, isLinearElement } from "../index";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles";
import { API } from "./helpers/api";
@@ -433,4 +433,49 @@ describe("element binding", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).toBe(null);
});
it("should not unbind when duplicating via selection group", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
y: 200,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 177,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
mouse.downAt(-100, -100);
mouse.moveTo(650, 750);
mouse.up(0, 0);
expect(API.getSelectedElements().length).toBe(3);
mouse.moveTo(5, 5);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(5, 5);
mouse.moveTo(1000, 1000);
mouse.up(0, 0);
expect(window.h.elements.length).toBe(6);
window.h.elements.forEach((element) => {
if (isLinearElement(element)) {
expect(element.startBinding).not.toBe(null);
expect(element.endBinding).not.toBe(null);
} else {
expect(element.boundElements).not.toBe(null);
}
});
});
});
});
@@ -13,6 +13,7 @@ import type { NormalizedZoomValue } from "../types";
import { API } from "./helpers/api";
import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
import { arrayToMap } from "../utils";
import { mockMermaidToExcalidraw } from "./helpers/mocks";
const { h } = window;
@@ -435,3 +436,83 @@ describe("pasting & frames", () => {
});
});
});
describe("clipboard - pasting mermaid definition", () => {
beforeAll(() => {
mockMermaidToExcalidraw({
parseMermaidToExcalidraw: async (definition) => {
const lines = definition.split("\n");
return new Promise((resolve, reject) => {
if (lines.some((line) => line === "flowchart TD")) {
resolve({
elements: [
{
id: "rect1",
type: "rectangle",
groupIds: [],
x: 0,
y: 0,
width: 69.703125,
height: 44,
strokeWidth: 2,
label: {
groupIds: [],
text: "A",
fontSize: 20,
},
link: null,
},
],
});
} else {
reject(new Error("ERROR"));
}
});
},
});
});
it("should detect and paste as mermaid", async () => {
const text = "flowchart TD\nA";
pasteWithCtrlCmdV(text);
await waitFor(() => {
expect(h.elements.length).toEqual(2);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "rectangle" }),
expect.objectContaining({ type: "text", text: "A" }),
]),
);
});
});
it("should support directives", async () => {
const text = "%%{init: { **config** } }%%\nflowchart TD\nA";
pasteWithCtrlCmdV(text);
await waitFor(() => {
expect(h.elements.length).toEqual(2);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "rectangle" }),
expect.objectContaining({ type: "text", text: "A" }),
]),
);
});
});
it("should paste as normal text if invalid mermaid", async () => {
const text = "flowchart TD xx\nA";
pasteWithCtrlCmdV(text);
await waitFor(() => {
expect(h.elements.length).toEqual(2);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "text", text: "flowchart TD xx" }),
expect.objectContaining({ type: "text", text: "A" }),
]),
);
});
});
});
@@ -0,0 +1,32 @@
import { vi } from "vitest";
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import React from "react";
export const mockMermaidToExcalidraw = (opts: {
parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw;
mockRef?: boolean;
}) => {
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
const module = (await importActual()) as any;
return {
__esModule: true,
...module,
};
});
const parseMermaidToExcalidrawSpy = vi.spyOn(
MermaidToExcalidraw,
"parseMermaidToExcalidraw",
);
parseMermaidToExcalidrawSpy.mockImplementation(opts.parseMermaidToExcalidraw);
if (opts.mockRef) {
vi.spyOn(React, "useRef").mockReturnValue({
current: {
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
},
});
}
};
+6
View File
@@ -559,4 +559,10 @@ export class UI {
".context-menu",
) as HTMLElement | null;
};
static queryStats = () => {
return GlobalTestState.renderResult.container.querySelector(
".Stats",
) as HTMLElement | null;
};
}
@@ -27,6 +27,7 @@ import * as textElementUtils from "../element/textElement";
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { vi } from "vitest";
import { arrayToMap } from "../utils";
import React from "react";
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
@@ -972,10 +973,10 @@ describe("Test Linear Elements", () => {
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
"Online whiteboard
collaboration made
easy"
`);
});
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
@@ -1006,10 +1007,10 @@ describe("Test Linear Elements", () => {
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
"Online whiteboard
collaboration made
easy"
`);
});
it("should not bind text to line when double clicked", async () => {
@@ -1051,11 +1052,11 @@ describe("Test Linear Elements", () => {
arrayToMap(h.elements),
),
).toMatchInlineSnapshot(`
{
"x": 75,
"y": 60,
}
`);
{
"x": 75,
"y": 60,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
@@ -1349,4 +1350,27 @@ describe("Test Linear Elements", () => {
expect(label.y).toBe(0);
});
});
describe("Test moving linear element points", () => {
it("should move the endpoint in the negative direction correctly when the start point is also moved in the positive direction", async () => {
const line = createThreePointerLinearElement("arrow");
const [origStartX, origStartY] = [line.x, line.y];
LinearElementEditor.movePoints(line, [
{ index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
{
index: line.points.length - 1,
point: [
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
],
},
]);
expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10);
expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).toBe(-20);
});
});
});
+10 -3
View File
@@ -197,6 +197,7 @@ export type InteractiveCanvasAppState = Readonly<
// SnapLines
snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"];
editingElement: AppState["editingElement"];
}
>;
@@ -335,7 +336,11 @@ export interface AppState {
fileHandle: FileSystemHandle | null;
collaborators: Map<SocketId, Collaborator>;
showStats: boolean;
stats: {
open: boolean;
/** bitmap. Use `STATS_PANELS` bit values */
panels: number;
};
currentChartType: ChartType;
pasteDialog:
| {
@@ -439,7 +444,9 @@ export interface ExcalidrawProps {
appState: AppState,
files: BinaryFiles,
) => void;
initialData?: MaybePromise<ExcalidrawInitialDataState | null>;
initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>;
excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
isCollaborating?: boolean;
onPointerUpdate?: (payload: {
@@ -592,6 +599,7 @@ export type AppClassProperties = {
files: BinaryFiles;
device: App["device"];
scene: App["scene"];
syncActionResult: App["syncActionResult"];
pasteFromClipboard: App["pasteFromClipboard"];
id: App["id"];
onInsertElements: App["onInsertElements"];
@@ -606,7 +614,6 @@ export type AppClassProperties = {
setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicframeToolSelect: App["onMagicframeToolSelect"];
getElementShape: App["getElementShape"];
getName: App["getName"];
};
+1
View File
@@ -43,6 +43,7 @@ interface ImportMetaEnv {
VITE_APP_COLLAPSE_OVERLAY: string;
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
VITE_APP_ENABLE_TRACKING: string;
VITE_PKG_NAME: string;
VITE_PKG_VERSION: string;
@@ -84,10 +84,13 @@ exports[`exportToSvg > with default arguments 1`] = `
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
},
"suggestedBindings": [],
"theme": "light",
"toast": null,
+6 -6
View File
@@ -1930,10 +1930,10 @@
resolved "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.0.0.tgz#8c058d2a43230425cba96d01e4a669a2d7c586a2"
integrity sha512-RGSoJBY2gFag6mQOIwa3OakTrvAZYx0bwvnr5ojuCZInih8Fxhje4X1WZfsaQx+GATEH8Ioq3O3b1FPDg4nKjQ==
"@excalidraw/mermaid-to-excalidraw@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.0.tgz#a24a7aa3ad2e4f671054fdb670a8508bab463814"
integrity sha512-YP2roqrImzek1SpUAeToSTNhH5Gfw9ogdI5KHp7c+I/mX7SEW8oNqqX7CP+oHcUgNF6RrYIkqSrnMRN9/3EGLg==
dependencies:
"@excalidraw/markdown-to-text" "0.1.2"
mermaid "10.9.0"
@@ -7753,9 +7753,9 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
lz-string@^1.5.0:
lz-string@1.5.0, lz-string@^1.5.0:
version "1.5.0"
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-string@^0.25.0, magic-string@^0.25.7: