mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
feat: editable element stats (#6382)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
22b39277f5
commit
d2f67e619f
40 changed files with 3596 additions and 413 deletions
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -135,7 +135,8 @@ export type ActionName =
|
|||
| "createContainerFromText"
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize";
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -2135,95 +2135,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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
77
packages/excalidraw/components/Stats/Angle.tsx
Normal file
77
packages/excalidraw/components/Stats/Angle.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ElementsMap, 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 } from "./utils";
|
||||
|
||||
interface AngleProps {
|
||||
element: ExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 15;
|
||||
|
||||
const Angle = ({ element, elementsMap }: AngleProps) => {
|
||||
const handleDegreeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
mutateElement(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
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(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label="A"
|
||||
icon={angleIcon}
|
||||
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={isPropertyEditable(element, "angle")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Angle;
|
39
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
39
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
|
@ -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;
|
126
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
126
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
import type { ElementsMap, 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";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
property: "width" | "height";
|
||||
element: ExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
||||
return element.type === "image";
|
||||
};
|
||||
|
||||
const DimensionDragInput = ({
|
||||
property,
|
||||
element,
|
||||
elementsMap,
|
||||
}: DimensionDragInputProps) => {
|
||||
const handleDimensionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const keepAspectRatio =
|
||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
|
||||
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,
|
||||
element,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
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,
|
||||
element,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DimensionDragInput;
|
75
packages/excalidraw/components/Stats/DragInput.scss
Normal file
75
packages/excalidraw/components/Stats/DragInput.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
247
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
247
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
|
@ -0,0 +1,247 @@
|
|||
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 "./DragInput.scss";
|
||||
import clsx from "clsx";
|
||||
import { useApp } from "../App";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
import { StoreAction } from "../../store";
|
||||
|
||||
export type DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}: {
|
||||
accumulatedChange: number;
|
||||
instantChange: number;
|
||||
originalElements: readonly ExcalidrawElement[];
|
||||
originalElementsMap: ElementsMap;
|
||||
shouldKeepAspectRatio: boolean;
|
||||
shouldChangeByStepSize: boolean;
|
||||
nextValue?: number;
|
||||
}) => void;
|
||||
|
||||
interface StatsDragInputProps {
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
value: number | "Mixed";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
editable?: boolean;
|
||||
shouldKeepAspectRatio?: boolean;
|
||||
dragInputCallback: DragInputCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = ({
|
||||
label,
|
||||
icon,
|
||||
dragInputCallback,
|
||||
value,
|
||||
elements,
|
||||
editable = true,
|
||||
shouldKeepAspectRatio,
|
||||
}: StatsDragInputProps) => {
|
||||
const app = useApp();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useState(value.toString());
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value.toString());
|
||||
}, [value, elements]);
|
||||
|
||||
const handleInputValue = (v: string) => {
|
||||
const parsed = Number(v);
|
||||
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) {
|
||||
dragInputCallback({
|
||||
accumulatedChange: 0,
|
||||
instantChange: 0,
|
||||
originalElements: elements,
|
||||
originalElementsMap: app.scene.getNonDeletedElementsMap(),
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: false,
|
||||
nextValue: rounded,
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return editable ? (
|
||||
<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 originalElements: ExcalidrawElement[] | null = null;
|
||||
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
||||
null;
|
||||
|
||||
let accumulatedChange: number | null = null;
|
||||
|
||||
document.body.classList.add("excalidraw-cursor-resize");
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!originalElementsMap) {
|
||||
originalElementsMap = app.scene
|
||||
.getNonDeletedElements()
|
||||
.reduce((acc, element) => {
|
||||
acc.set(element.id, deepCopyElement(element));
|
||||
return acc;
|
||||
}, new Map() as ElementsMap);
|
||||
}
|
||||
|
||||
if (!originalElements) {
|
||||
originalElements = elements.map(
|
||||
(element) => originalElementsMap!.get(element.id)!,
|
||||
);
|
||||
}
|
||||
|
||||
if (!accumulatedChange) {
|
||||
accumulatedChange = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
lastPointer &&
|
||||
originalElementsMap !== null &&
|
||||
accumulatedChange !== null
|
||||
) {
|
||||
const instantChange = event.clientX - lastPointer.x;
|
||||
accumulatedChange += instantChange;
|
||||
|
||||
dragInputCallback({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: event.shiftKey,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
app.focusContainer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
setInputValue(event.target.value);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!inputValue) {
|
||||
setInputValue(value.toString());
|
||||
} else if (editable) {
|
||||
handleInputValue(event.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDragInput;
|
75
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
75
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
|
||||
import { refreshTextDimensions } from "../../element/newElement";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
import { fontSizeIcon } from "../icons";
|
||||
|
||||
interface FontSizeProps {
|
||||
element: ExcalidrawTextElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const STEP_SIZE = 4;
|
||||
|
||||
const FontSize = ({ element, elementsMap }: FontSizeProps) => {
|
||||
const handleFontSizeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
if (nextValue !== undefined) {
|
||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
const newElement = {
|
||||
...element,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(element, {
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (origElement.type === "text") {
|
||||
const originalFontSize = Math.round(origElement.fontSize);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
let nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
const newElement = {
|
||||
...element,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(element, {
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
value={Math.round(element.fontSize * 10) / 10}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
icon={fontSizeIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSize;
|
114
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
114
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ElementsMap, 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";
|
||||
|
||||
interface MultiAngleProps {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 15;
|
||||
|
||||
const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
|
||||
const handleDegreeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const editableLatestIndividualElements = elements.filter(
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||
);
|
||||
const editableOriginalIndividualElements = originalElements.filter(
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||
);
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
|
||||
for (const element of editableLatestIndividualElements) {
|
||||
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];
|
||||
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 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiAngle;
|
377
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
377
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
|
@ -0,0 +1,377 @@
|
|||
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 } from "../../element/types";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { Point } from "../../types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { 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: ElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
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: ElementsMap,
|
||||
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: ElementsMap,
|
||||
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 MultiDimension = ({
|
||||
property,
|
||||
elements,
|
||||
elementsMap,
|
||||
atomicUnits,
|
||||
scene,
|
||||
}: 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;
|
||||
|
||||
const handleDimensionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
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,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
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,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={elements}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiDimension;
|
115
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
115
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { isTextElement, refreshTextDimensions } from "../../element";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { isBoundToContainer } from "../../element/typeChecks";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
} 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";
|
||||
|
||||
interface MultiFontSizeProps {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const STEP_SIZE = 4;
|
||||
|
||||
const MultiFontSize = ({
|
||||
elements,
|
||||
elementsMap,
|
||||
scene,
|
||||
}: MultiFontSizeProps) => {
|
||||
const latestTextElements = elements.filter(
|
||||
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
|
||||
) as ExcalidrawTextElement[];
|
||||
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;
|
||||
|
||||
const handleFontSizeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
if (nextValue) {
|
||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
for (const textElement of latestTextElements) {
|
||||
const newElement = {
|
||||
...textElement,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalTextElements = originalElements.filter(
|
||||
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
|
||||
) 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);
|
||||
}
|
||||
const newElement = {
|
||||
...latestElement,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
icon={fontSizeIcon}
|
||||
elements={elements}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiFontSize;
|
239
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
239
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
|
@ -0,0 +1,239 @@
|
|||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getCommonBounds, isTextElement } from "../../element";
|
||||
import { useMemo } from "react";
|
||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
|
||||
interface MultiPositionProps {
|
||||
property: "x" | "y";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const moveElements = (
|
||||
property: MultiPositionProps["property"],
|
||||
changeInTopX: number,
|
||||
changeInTopY: number,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
originalElements: readonly ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
const latestElement = elements[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,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const moveGroupTo = (
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
latestElements: ExcalidrawElement[],
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x1, y1, ,] = getCommonBounds(originalElements);
|
||||
const offsetX = nextX - x1;
|
||||
const offsetY = nextY - y1;
|
||||
|
||||
for (let i = 0; i < latestElements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
const latestElement = latestElements[i];
|
||||
|
||||
// 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,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MultiPosition = ({
|
||||
property,
|
||||
elements,
|
||||
elementsMap,
|
||||
atomicUnits,
|
||||
scene,
|
||||
}: 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";
|
||||
|
||||
const handlePositionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
if (nextValue !== undefined) {
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
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.latest),
|
||||
elementsInUnit.map((el) => el.original),
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
} 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,
|
||||
latestElement,
|
||||
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,
|
||||
elements,
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={elements}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiPosition;
|
101
packages/excalidraw/components/Stats/Position.tsx
Normal file
101
packages/excalidraw/components/Stats/Position.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
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";
|
||||
|
||||
interface PositionProps {
|
||||
property: "x" | "y";
|
||||
element: ExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const Position = ({ property, element, elementsMap }: 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;
|
||||
|
||||
const handlePositionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
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,
|
||||
element,
|
||||
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,
|
||||
element,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Position;
|
306
packages/excalidraw/components/Stats/index.tsx
Normal file
306
packages/excalidraw/components/Stats/index.tsx
Normal file
|
@ -0,0 +1,306 @@
|
|||
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,
|
||||
getElementsInGroup,
|
||||
getSelectedGroupIds,
|
||||
isInGroup,
|
||||
} 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 type { AtomicUnit } 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(() => {
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
const _atomicUnits = selectedGroupIds.map((gid) => {
|
||||
return getElementsInGroup(selectedElements, gid).reduce((acc, el) => {
|
||||
acc[el.id] = true;
|
||||
return acc;
|
||||
}, {} as AtomicUnit);
|
||||
});
|
||||
selectedElements
|
||||
.filter((el) => !isInGroup(el))
|
||||
.forEach((el) => {
|
||||
_atomicUnits.push({
|
||||
[el.id]: true,
|
||||
});
|
||||
});
|
||||
return _atomicUnits;
|
||||
}, [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}
|
||||
/>
|
||||
<Position
|
||||
element={singleElement}
|
||||
property="y"
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Dimension
|
||||
property="width"
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Dimension
|
||||
property="height"
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Angle
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
{singleElement.type === "text" && (
|
||||
<FontSize
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
<MultiPosition
|
||||
property="y"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="width"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="height"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
/>
|
||||
<MultiAngle
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
/>
|
||||
<MultiFontSize
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
/>
|
||||
</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
|
||||
);
|
||||
},
|
||||
);
|
658
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
658
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
|
@ -0,0 +1,658 @@
|
|||
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 } from "../..";
|
||||
import { t } from "../../i18n";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
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";
|
||||
|
||||
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 getStatsProperty = (label: string) => {
|
||||
if (elementStats) {
|
||||
const properties = elementStats?.querySelector(".statsItem");
|
||||
return properties?.querySelector?.(
|
||||
`.drag-input-container[data-testid="${label}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
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).not.toBeNull();
|
||||
expect(input.value).toBe(initialValue.toString());
|
||||
input?.focus();
|
||||
input.value = nextValue.toString();
|
||||
input?.blur();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 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).not.toBeNull();
|
||||
expect(elementStats).not.toBeNull();
|
||||
|
||||
// title
|
||||
const title = elementStats?.querySelector("h3");
|
||||
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
||||
|
||||
// element type
|
||||
const elementType = elementStats?.querySelector(".elementType");
|
||||
expect(elementType).not.toBeNull();
|
||||
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||
|
||||
// properties
|
||||
const properties = elementStats?.querySelector(".statsItem");
|
||||
expect(properties?.childNodes).not.toBeNull();
|
||||
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||
expect(
|
||||
properties?.querySelector?.(
|
||||
`.drag-input-container[data-testid="${label}"]`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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).not.toBeNull();
|
||||
expect(input.value).toBe(rectangle.width.toString());
|
||||
input?.focus();
|
||||
input.value = "123.123";
|
||||
input?.blur();
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(rectangle.id).toBe(rectangleId);
|
||||
expect(input.value).toBe("123.12");
|
||||
expect(rectangle.width).toBe(123.12);
|
||||
|
||||
input?.focus();
|
||||
input.value = "88.98766";
|
||||
input?.blur();
|
||||
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).not.toBeNull();
|
||||
expect(input.value).toBe(text.fontSize.toString());
|
||||
input?.focus();
|
||||
input.value = "36";
|
||||
input?.blur();
|
||||
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
|
||||
input.focus();
|
||||
input.value = "0";
|
||||
input.blur();
|
||||
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).not.toBeNull();
|
||||
|
||||
// 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).not.toBeNull();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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");
|
||||
|
||||
width.focus();
|
||||
width.value = "250";
|
||||
width.blur();
|
||||
h.elements.forEach((el) => {
|
||||
expect(el.width).toBe(250);
|
||||
});
|
||||
|
||||
height.focus();
|
||||
height.value = "450";
|
||||
height.blur();
|
||||
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({
|
||||
id: "id0",
|
||||
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).not.toBeNull();
|
||||
expect(width.value).toBe("Mixed");
|
||||
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).not.toBeNull();
|
||||
expect(height.value).toBe("Mixed");
|
||||
|
||||
const angle = getStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(angle).not.toBeNull();
|
||||
expect(angle.value).toBe("0");
|
||||
|
||||
const fontSize = getStatsProperty("F")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).not.toBeNull();
|
||||
|
||||
// changing width does not affect text
|
||||
width.focus();
|
||||
width.value = "200";
|
||||
width.blur();
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).not.toBe(200);
|
||||
|
||||
angle.focus();
|
||||
angle.value = "40";
|
||||
angle.blur();
|
||||
|
||||
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).not.toBeNull();
|
||||
expect(Number(x.value)).toBe(x1);
|
||||
|
||||
x.focus();
|
||||
x.value = "300";
|
||||
x.blur();
|
||||
|
||||
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).not.toBeNull();
|
||||
expect(Number(y.value)).toBe(y1);
|
||||
|
||||
y.focus();
|
||||
y.value = "200";
|
||||
y.blur();
|
||||
|
||||
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).not.toBeNull();
|
||||
expect(Number(width.value)).toBe(200);
|
||||
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).not.toBeNull();
|
||||
expect(Number(height.value)).toBe(200);
|
||||
|
||||
width.focus();
|
||||
width.value = "400";
|
||||
width.blur();
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
let newGroupWidth = x2 - x1;
|
||||
|
||||
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||
|
||||
width.focus();
|
||||
width.value = "300";
|
||||
width.blur();
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
newGroupWidth = x2 - x1;
|
||||
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||
|
||||
height.focus();
|
||||
height.value = "500";
|
||||
height.blur();
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
const newGroupHeight = y2 - y1;
|
||||
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||
});
|
||||
});
|
238
packages/excalidraw/components/Stats/utils.ts
Normal file
238
packages/excalidraw/components/Stats/utils.ts
Normal file
|
@ -0,0 +1,238 @@
|
|||
import { 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, isTextElement } from "../../element/typeChecks";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
import { getFontString } from "../../utils";
|
||||
|
||||
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,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: Map<string, ExcalidrawElement>,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundElements(latestElement, elementsMap, {
|
||||
newSize: {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
},
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
|
||||
};
|
||||
|
||||
export const moveElement = (
|
||||
newTopLeftX: number,
|
||||
newTopLeftY: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
originalElement: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -405,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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -178,7 +178,7 @@ const rotateSingleElement = (
|
|||
}
|
||||
};
|
||||
|
||||
const rescalePointsInElement = (
|
||||
export const rescalePointsInElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
|
@ -195,7 +195,7 @@ const rescalePointsInElement = (
|
|||
}
|
||||
: {};
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
export const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
elementsMap: ElementsMap,
|
||||
nextWidth: number,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -559,4 +559,10 @@ export class UI {
|
|||
".context-menu",
|
||||
) as HTMLElement | null;
|
||||
};
|
||||
|
||||
static queryStats = () => {
|
||||
return GlobalTestState.renderResult.container.querySelector(
|
||||
".Stats",
|
||||
) as HTMLElement | null;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -336,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:
|
||||
| {
|
||||
|
@ -593,6 +597,7 @@ export type AppClassProperties = {
|
|||
files: BinaryFiles;
|
||||
device: App["device"];
|
||||
scene: App["scene"];
|
||||
syncActionResult: App["syncActionResult"];
|
||||
pasteFromClipboard: App["pasteFromClipboard"];
|
||||
id: App["id"];
|
||||
onInsertElements: App["onInsertElements"];
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue