Compare commits

...
Author SHA1 Message Date
26d2296578 fix: fixed copy to clipboard button (#8426)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-27 00:27:44 +02:00
zsviczianandGitHub afb68a6467 feat: improve elbow arrow keyboard move (#8392) 2024-08-26 15:58:54 +02:00
b459e5cfd2 fix: context menu does not work after after dragging on StatsDragInput (#8386)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-26 15:58:46 +02:00
David LuzarandGitHub 5facc0d6da fix: perf regression in getCommonBounds (#8429) 2024-08-26 15:57:28 +02:00
Paul TuneandGitHub 824ad603e1 docs: Update function call in @excalidraw/mermaid-to-excalidraw (#8420) 2024-08-26 13:20:29 +02:00
5e1ff7cafe perf: improve new element drawing (#8340)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-23 20:27:57 +02:00
David LuzarandGitHub b5d7f5b4ba feat: rewrite d2c to not require token (#8269) 2024-08-20 18:06:22 +02:00
David LuzarandGitHub fb4bb29aa5 fix: object snapping not working (#8381) 2024-08-15 18:48:25 +02:00
David LuzarandGitHub 3cfcc7b489 feat: split gridSize from enabled state & support custom gridStep (#8364) 2024-08-14 14:59:14 +02:00
David LuzarandGitHub 4320a3cf41 feat: improve zoom-to-content when creating flowchart (#8368) 2024-08-12 20:42:08 +02:00
Márk TolmácsandGitHub 8420e1aa13 fix: Reimplement rectangle intersection (#8367) 2024-08-12 19:19:16 +02:00
Márk TolmácsandGitHub 5daf1a1b4e fix: Round coordinates and sizes for rectangle intersection (#8366)
Round coordinates and sizes for rectangle intersection
2024-08-12 11:07:58 +02:00
David LuzarandGitHub 97981804d7 feat: Stats popup style tweaks (#8361) 2024-08-11 19:33:44 +02:00
f7b3befd0a fix: text content with tab characters act different in view/edit (#8336)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-09 20:20:36 +00:00
DDDDD12138andGitHub 7b2bee9746 chore: remove unused parameter (#8355) 2024-08-09 21:39:40 +02:00
David LuzarandGitHub 88014ace4a fix: drawing from 0-dimension canvas (#8356) 2024-08-09 21:36:04 +02:00
David LuzarandGitHub 87a9430809 fix: disable flowchart keybindings inside inputs (#8353) 2024-08-09 18:44:17 +02:00
Márk TolmácsandGitHub 99b91c46f7 fix: Yet more patching of intersect code (#8352)
* Yet more patching of intersect code
2024-08-09 17:33:12 +02:00
David LuzarandGitHub 1ea5b26f25 fix: missing act() in flowchart tests (#8354) 2024-08-09 17:27:02 +02:00
105 changed files with 2772 additions and 2171 deletions
@@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import { convertToExcalidrawElements} from "@excalidraw/excalidraw" import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
try { try {
const { elements, files } = await parseMermaid(mermaidSyntax: string, { const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
fontSize: number, fontSize: number,
}); });
const excalidrawElements = convertToExcalidrawElements(elements); const excalidrawElements = convertToExcalidrawElements(elements);
+1 -1
View File
@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
// editor state (canvas config, preferences, ...) // editor state (canvas config, preferences, ...)
"appState": { "appState": {
"gridSize": null, "gridSize": 20,
"viewBackgroundColor": "#ffffff" "viewBackgroundColor": "#ffffff"
}, },
+2 -57
View File
@@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, StoreAction,
reconcileElements, reconcileElements,
@@ -121,6 +120,7 @@ import {
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector"; import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state"; import { useAppLangCode } from "./app-language/language-state";
import { AIComponents } from "./components/AI";
polyfill(); polyfill();
@@ -846,63 +846,8 @@ const ExcalidrawWrapper = () => {
)} )}
</OverwriteConfirmDialog> </OverwriteConfirmDialog>
<AppFooter /> <AppFooter />
<TTDDialog {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="collab-offline-warning">
+16 -21
View File
@@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types"; import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
@@ -51,24 +52,9 @@ const CustomStats = (props: Props) => {
} }
return ( return (
<> <Stats.StatsRows order={-1}>
<tr> <Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<th colSpan={2}>{t("stats.storage")}</th> <Stats.StatsRow
</tr>
<tr>
<td>{t("stats.scene")}</td>
<td>{nFormatter(storageSizes.scene, 1)}</td>
</tr>
<tr>
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }} style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => { onClick={async () => {
try { try {
@@ -81,9 +67,18 @@ const CustomStats = (props: Props) => {
{timestamp} {timestamp}
<br /> <br />
{hash} {hash}
</td> </Stats.StatsRow>
</tr>
</> <Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.scene")}</div>
<div>{nFormatter(storageSizes.scene, 1)}</div>
</Stats.StatsRow>
<Stats.StatsRow columns={2}>
<div>{t("stats.total")}</div>
<div>{nFormatter(storageSizes.total, 1)}</div>
</Stats.StatsRow>
</Stats.StatsRows>
); );
}; };
+9 -3
View File
@@ -116,20 +116,26 @@ class Portal {
} }
} }
this.collab.excalidrawAPI.updateScene({ let isChanged = false;
elements: this.collab.excalidrawAPI const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server // this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break // (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging) // in-progress dragging)
return newElementWith(element, { status: "saved" }); return newElementWith(element, { status: "saved" });
} }
return element; return element;
}), });
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
}
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);
broadcastScene = async ( broadcastScene = async (
-218
View File
@@ -1,218 +0,0 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils";
import { useI18n } from "../../packages/excalidraw/i18n";
import { KEYS } from "../../packages/excalidraw/keys";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import {
copyIcon,
playerPlayIcon,
playerStopFilledIcon,
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
import "./RoomDialog.scss";
const getShareIcon = () => {
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
if (isAppleBrowser) {
return shareIOS;
} else if (isWindowsBrowser) {
return shareWindows;
}
return share;
};
export type RoomModalProps = {
handleClose: () => void;
activeRoomLink: string;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
};
export const RoomModal = ({
activeRoomLink,
onRoomCreate,
onRoomDestroy,
setErrorMessage,
username,
onUsernameChange,
handleClose,
}: RoomModalProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
const shareRoomLink = async () => {
try {
await navigator.share({
title: t("roomDialog.shareTitle"),
text: t("roomDialog.shareTitle"),
url: activeRoomLink,
});
} catch (error: any) {
// Just ignore.
}
};
if (activeRoomLink) {
return (
<>
<h3 className="RoomDialog__active__header">
{t("labels.liveCollaboration")}
</h3>
<TextField
value={username}
placeholder="Your name"
label="Your name"
onChange={onUsernameChange}
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
/>
<div className="RoomDialog__active__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={activeRoomLink}
/>
{isShareSupported && (
<FilledButton
size="large"
variant="icon"
label="Share"
icon={getShareIcon()}
className="RoomDialog__active__share"
onClick={shareRoomLink}
/>
)}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="RoomDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="RoomDialog__active__description">
<p>
<span
role="img"
aria-hidden="true"
className="RoomDialog__active__description__emoji"
>
🔒{" "}
</span>
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
</div>
<div className="RoomDialog__active__actions">
<FilledButton
size="large"
variant="outlined"
color="danger"
label={t("roomDialog.button_stopSession")}
icon={playerStopFilledIcon}
onClick={() => {
trackEvent("share", "room closed");
onRoomDestroy();
}}
/>
</div>
</>
);
}
return (
<>
<div className="RoomDialog__inactive__illustration">
<CollabImage />
</div>
<div className="RoomDialog__inactive__header">
{t("labels.liveCollaboration")}
</div>
<div className="RoomDialog__inactive__description">
<strong>{t("roomDialog.desc_intro")}</strong>
{t("roomDialog.desc_privacy")}
</div>
<div className="RoomDialog__inactive__start_session">
<FilledButton
size="large"
label={t("roomDialog.button_startSession")}
icon={playerPlayIcon}
onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
onRoomCreate();
}}
/>
</div>
</>
);
};
const RoomDialog = (props: RoomModalProps) => {
return (
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
<div className="RoomDialog">
<RoomModal {...props} />
</div>
</Dialog>
);
};
export default RoomDialog;
+159
View File
@@ -0,0 +1,159 @@
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
import {
DiagramToCodePlugin,
exportToBlob,
getTextFromElements,
MIME_TYPES,
TTDDialog,
} from "../../packages/excalidraw";
import { getDataURL } from "../../packages/excalidraw/data/blob";
import { safelyParseJSON } from "../../packages/excalidraw/utils";
export const AIComponents = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI;
}) => {
return (
<>
<DiagramToCodePlugin
generate={async ({ frame, children }) => {
const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
const textFromFrameChildren = getTextFromElements(children);
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/diagram-to-code/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
texts: textFromFrameChildren,
image: dataURL,
theme: appState.theme,
}),
},
);
if (!response.ok) {
const text = await response.text();
const errorJSON = safelyParseJSON(text);
if (!errorJSON) {
throw new Error(text);
}
if (errorJSON.statusCode === 429) {
return {
html: `<html>
<body style="margin: 0; text-align: center">
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
</br>
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,
};
}
throw new Error(errorJSON.message || text);
}
try {
const { html } = await response.json();
if (!html) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
} catch (error: any) {
throw new Error("Generation failed (invalid response)");
}
}}
/>
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
</>
);
};
+2 -2
View File
@@ -58,8 +58,8 @@
font-size: 0.75rem; font-size: 0.75rem;
line-height: 110%; line-height: 110%;
background: var(--color-success-lighter); background: var(--color-success);
color: var(--color-success); color: var(--color-success-text);
& > svg { & > svg {
width: 0.875rem; width: 0.875rem;
+9 -19
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils"; import { getFrame } from "../../packages/excalidraw/utils";
@@ -14,7 +13,6 @@ import {
share, share,
shareIOS, shareIOS,
shareWindows, shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons"; } from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField"; import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss"; import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
type OnExportToBackend = () => void; type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly"; type ShareDialogType = "share" | "collaborationOnly";
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
handleClose: () => void; handleClose: () => void;
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false); const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator; const isShareSupported = "share" in navigator;
const { onCopy, copyStatus } = useCopyStatus();
const copyRoomLink = async () => { const copyRoomLink = async () => {
try { try {
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
onClick={shareRoomLink} onClick={shareRoomLink}
/> />
)} )}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton <FilledButton
size="large" size="large"
label="Copy link" label={t("buttons.copyLink")}
icon={copyIcon} icon={copyIcon}
onClick={copyRoomLink} status={copyStatus}
onClick={() => {
copyRoomLink();
onCopy();
}}
/> />
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div> </div>
<div className="ShareDialog__active__description"> <div className="ShareDialog__active__description">
<p> <p>
+2
View File
@@ -39,6 +39,8 @@ Please add the latest change on the top under the correct section.
### Breaking Changes ### Breaking Changes
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898) - `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes | | | Before `commitToHistory` | After `storeAction` | Notes |
+22 -16
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import type { AppState, NormalizedZoomValue } from "../types"; import type { AppState } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
@@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { clamp } from "../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -104,6 +105,8 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats, stats: appState.stats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
const zoomValueToFitBoundsOnViewport = ( const zoomValueToFitBoundsOnViewport = (
bounds: SceneBounds, bounds: SceneBounds,
viewportDimensions: { width: number; height: number }, viewportDimensions: { width: number; height: number },
viewportZoomFactor: number = 1, // default to 1 if not provided
) => { ) => {
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
@@ -251,20 +255,21 @@ const zoomValueToFitBoundsOnViewport = (
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM), return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 0.7, viewportZoomFactor = 1,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
appState: Readonly<AppState>; appState: Readonly<AppState>;
@@ -289,13 +294,9 @@ export const zoomToFitBounds = ({
Math.min( Math.min(
appState.width / commonBoundsWidth, appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight, appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1)); ) * clamp(viewportZoomFactor, 0.1, 1);
// Apply clamping to newZoomValue to be between 10% and 3000% newZoomValue = getNormalizedZoom(newZoomValue);
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width; let appStateWidth = appState.width;
@@ -314,10 +315,14 @@ export const zoomToFitBounds = ({
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { newZoomValue = zoomValueToFitBoundsOnViewport(
bounds,
{
width: appState.width, width: appState.width,
height: appState.height, height: appState.height,
}); },
viewportZoomFactor,
);
const centerScroll = centerScrollOn({ const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY }, scenePoint: { x: centerX, y: centerY },
@@ -408,6 +413,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7,
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
@@ -10,7 +10,7 @@ import {
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index"; import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element"; import { getTextFromElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants"; import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -239,16 +239,8 @@ export const copyText = register({
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
try { try {
copyTextToSystemClipboard(text); copyTextToSystemClipboard(getTextFromElements(selectedElements));
} catch (e) { } catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed")); throw new Error(t("errors.copyToSystemClipboardFailed"));
} }
@@ -15,7 +15,7 @@ import {
import type { AppState } from "../types"; import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding"; import { fixBindingsAfterDuplication } from "../element/binding";
import type { ActionResult } from "./types"; import type { ActionResult } from "./types";
import { GRID_SIZE } from "../constants"; import { DEFAULT_GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
@@ -99,8 +99,8 @@ const duplicateElements = (
groupIdMap, groupIdMap,
element, element,
{ {
x: element.x + GRID_SIZE / 2, x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2, y: element.y + DEFAULT_GRID_SIZE / 2,
}, },
); );
duplicatedElementsMap.set(newElement.id, newElement); duplicatedElementsMap.set(newElement.id, newElement);
@@ -50,7 +50,6 @@ export const actionFinalize = register({
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
selectedLinearElement: null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
@@ -179,7 +178,7 @@ export const actionFinalize = register({
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingTextElement: null,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
@@ -21,7 +21,7 @@ const executeHistoryAction = (
if ( if (
!appState.multiElement && !appState.multiElement &&
!appState.resizingElement && !appState.resizingElement &&
!appState.editingElement && !appState.editingTextElement &&
!appState.newElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
!appState.selectionElement && !appState.selectionElement &&
@@ -133,7 +133,7 @@ export const changeProperty = (
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || selectedElementIds.get(element.id) ||
element.id === appState.editingElement?.id element.id === appState.editingTextElement?.id
) { ) {
return callback(element); return callback(element);
} }
@@ -148,13 +148,13 @@ export const getFormValue = function <T extends Primitive>(
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingElement = appState.editingElement; const editingTextElement = appState.editingTextElement;
const nonDeletedElements = getNonDeletedElements(elements); const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null; let ret: T | null = null;
if (editingElement) { if (editingTextElement) {
ret = getAttribute(editingElement); ret = getAttribute(editingTextElement);
} }
if (!ret) { if (!ret) {
@@ -1076,19 +1076,20 @@ export const actionChangeFontFamily = register({
// open, populate the cache from scratch // open, populate the cache from scratch
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
const { editingElement } = appState; const { editingTextElement } = appState;
if (editingElement?.type === "text") { // still check type to be safe
// retrieve the latest version from the scene, as `editingElement` isn't mutated if (editingTextElement?.type === "text") {
const latestEditingElement = app.scene.getElement( // retrieve the latest version from the scene, as `editingTextElement` isn't mutated
editingElement.id, const latesteditingTextElement = app.scene.getElement(
editingTextElement.id,
); );
// inside the wysiwyg editor // inside the wysiwyg editor
cachedElementsRef.current.set( cachedElementsRef.current.set(
editingElement.id, editingTextElement.id,
newElementWith( newElementWith(
latestEditingElement || editingElement, latesteditingTextElement || editingTextElement,
{}, {},
true, true,
), ),
@@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { GRID_SIZE } from "../constants";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { gridIcon } from "../components/icons"; import { gridIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
viewMode: true, viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.gridSize, predicate: (appState) => appState.gridModeEnabled,
}, },
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {
...appState, ...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE, gridModeEnabled: !this.checked!(appState),
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => { predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined"; return props.gridModeEnabled === undefined;
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
}); });
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
appState: { appState: {
...appState, ...appState,
objectsSnapModeEnabled: !this.checked!(appState), objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null, gridModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
+9 -3
View File
@@ -5,9 +5,11 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES, EXPORT_SCALES,
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP,
} from "./constants"; } from "./constants";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@@ -42,7 +44,7 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
newElement: null, newElement: null,
editingElement: null, editingTextElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
activeTool: { activeTool: {
@@ -59,7 +61,9 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false, exportEmbedScene: false,
exportWithDarkMode: false, exportWithDarkMode: false,
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: DEFAULT_GRID_SIZE,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true, isBindingEnabled: true,
defaultSidebarDockedPreference: false, defaultSidebarDockedPreference: false,
isLoading: false, isLoading: false,
@@ -161,7 +165,7 @@ const APP_STATE_STORAGE_CONF = (<
cursorButton: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false }, newElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
@@ -174,6 +178,8 @@ const APP_STATE_STORAGE_CONF = (<
exportWithDarkMode: { browser: true, export: false, server: false }, exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false }, fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
gridStep: { browser: true, export: true, server: true },
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
+5 -18
View File
@@ -45,7 +45,6 @@ import {
frameToolIcon, frameToolIcon,
mermaidLogoIcon, mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
OpenAIIcon,
MagicIcon, MagicIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -104,7 +103,9 @@ export const SelectedShapeActions = ({
) { ) {
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const isEditing = Boolean(appState.editingElement); const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const device = useDevice(); const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@@ -234,7 +235,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
)} )}
{!isEditing && targetElements.length > 0 && ( {!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -400,7 +401,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.mermaidToExcalidraw")} {t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.props.aiEnabled !== false && ( {app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<> <>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
@@ -410,20 +411,6 @@ export const ShapesSwitcher = ({
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge> <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({
name: "settings",
source: "settings",
tab: "diagram-to-code",
});
}}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</DropdownMenu.Item>
</> </>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
import { useLayoutEffect } from "react";
import { useApp } from "../App";
import type { GenerateDiagramToCode } from "../../types";
export const DiagramToCodePlugin = (props: {
generate: GenerateDiagramToCode;
}) => {
const app = useApp();
useLayoutEffect(() => {
app.setPlugins({
diagramToCode: { generate: props.generate },
});
}, [app, props.generate]);
return null;
};
@@ -16,11 +16,19 @@
.Spinner { .Spinner {
--spinner-color: var(--color-surface-lowest); --spinner-color: var(--color-surface-lowest);
position: absolute;
visibility: visible;
} }
&[disabled] { .ExcButton__statusIcon {
visibility: visible;
position: absolute;
width: 1rem;
height: 1rem;
font-size: 1rem;
}
&.ExcButton--status-loading,
&.ExcButton--status-success {
pointer-events: none; pointer-events: none;
.ExcButton__contents { .ExcButton__contents {
@@ -28,6 +36,10 @@
} }
} }
&[disabled] {
pointer-events: none;
}
&, &,
&__contents { &__contents {
display: flex; display: flex;
@@ -119,6 +131,46 @@
} }
} }
&--color-success {
&.ExcButton--variant-filled {
--text-color: var(--color-success-text);
--back-color: var(--color-success);
.Spinner {
--spinner-color: var(--color-success);
}
&:hover {
--back-color: var(--color-success-darker);
}
&:active {
--back-color: var(--color-success-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-success-contrast);
--border-color: var(--color-success-contrast);
--back-color: transparent;
.Spinner {
--spinner-color: var(--color-success-contrast);
}
&:hover {
--text-color: var(--color-success-contrast-hover);
--border-color: var(--color-success-contrast-hover);
}
&:active {
--text-color: var(--color-success-contrast-active);
--border-color: var(--color-success-contrast-active);
}
}
}
&--color-muted { &--color-muted {
&.ExcButton--variant-filled { &.ExcButton--variant-filled {
--text-color: var(--island-bg-color); --text-color: var(--island-bg-color);
@@ -5,9 +5,15 @@ import "./FilledButton.scss";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { isPromiseLike } from "../utils"; import { isPromiseLike } from "../utils";
import { tablerCheckIcon } from "./icons";
export type ButtonVariant = "filled" | "outlined" | "icon"; export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger" | "warning" | "muted"; export type ButtonColor =
| "primary"
| "danger"
| "warning"
| "muted"
| "success";
export type ButtonSize = "medium" | "large"; export type ButtonSize = "medium" | "large";
export type FilledButtonProps = { export type FilledButtonProps = {
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
children?: React.ReactNode; children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
status?: null | "loading" | "success";
variant?: ButtonVariant; variant?: ButtonVariant;
color?: ButtonColor; color?: ButtonColor;
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
size = "medium", size = "medium",
fullWidth, fullWidth,
className, className,
status,
}, },
ref, ref,
) => { ) => {
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
const ret = onClick?.(event); const ret = onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
try { // delay loading state to prevent flicker in case of quick response
const timer = window.setTimeout(() => {
setIsLoading(true); setIsLoading(true);
}, 50);
try {
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
console.warn(error); console.warn(error);
} }
} finally { } finally {
clearTimeout(timer);
setIsLoading(false); setIsLoading(false);
} }
} }
}; };
const _status = isLoading ? "loading" : status;
color = _status === "success" ? "success" : color;
return ( return (
<button <button
className={clsx( className={clsx(
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`, `ExcButton--color-${color}`,
`ExcButton--variant-${variant}`, `ExcButton--variant-${variant}`,
`ExcButton--size-${size}`, `ExcButton--size-${size}`,
`ExcButton--status-${_status}`,
{ "ExcButton--fullWidth": fullWidth }, { "ExcButton--fullWidth": fullWidth },
className, className,
)} )}
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={isLoading} disabled={_status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{isLoading && <Spinner />} {_status === "loading" ? (
<Spinner className="ExcButton__statusIcon" />
) : (
_status === "success" && (
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
)
)}
{icon && ( {icon && (
<div className="ExcButton__icon" aria-hidden> <div className="ExcButton__icon" aria-hidden>
{icon} {icon}
@@ -12,6 +12,7 @@ import { isEraserActive } from "../appState";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart"; import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
@@ -86,7 +87,7 @@ const getHints = ({
return t("hints.text_selected"); return t("hints.text_selected");
} }
if (appState.editingElement && isTextElement(appState.editingElement)) { if (appState.editingTextElement) {
return t("hints.text_editing"); return t("hints.text_editing");
} }
@@ -94,13 +95,13 @@ const getHints = ({
if ( if (
appState.selectionElement && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.editingElement && !appState.editingTextElement &&
!appState.editingLinearElement !appState.editingLinearElement
) { ) {
return t("hints.deepBoxSelect"); return t("hints.deepBoxSelect");
} }
if (appState.gridSize && appState.selectedElementsAreBeingDragged) { if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping"); return t("hints.disableSnapping");
} }
@@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
import { FilledButton } from "./FilledButton"; import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils"; import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data"; import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -89,6 +90,8 @@ const ImageExportModal = ({
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus } = useCopyStatus();
const { exportedElements, exportingFrame } = prepareElementsForExport( const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot, elementsSnapshot,
appStateSnapshot, appStateSnapshot,
@@ -294,11 +297,17 @@ const ImageExportModal = ({
<FilledButton <FilledButton
className="ImageExportModal__settings__buttons__button" className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")} label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() => status={copyStatus}
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, { onClick={async () => {
await onExportImage(
EXPORT_IMAGE_TYPES.clipboard,
exportedElements,
{
exportingFrame, exportingFrame,
}) },
} );
onCopy();
}}
icon={copyIcon} icon={copyIcon}
> >
{t("imageExportDialog.button.copyPngToClipboard")} {t("imageExportDialog.button.copyPngToClipboard")}
@@ -27,99 +27,6 @@
& > * { & > * {
pointer-events: var(--ui-pointerEvents); 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 { &__footer {
+1 -33
View File
@@ -60,7 +60,6 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
@@ -85,14 +84,6 @@ interface LayerUIProps {
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean; isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@@ -149,10 +140,6 @@ const LayerUI = ({
children, children,
app, app,
isCollaborating, isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@@ -360,7 +347,7 @@ const LayerUI = ({
)} )}
{shouldShowStats && ( {shouldShowStats && (
<Stats <Stats
scene={app.scene} app={app}
onClose={() => { onClose={() => {
actionManager.executeAction(actionToggleStats); actionManager.executeAction(actionToggleStats);
}} }}
@@ -482,25 +469,6 @@ const LayerUI = ({
}} }}
/> />
)} )}
{appState.openDialog?.name === "settings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
const source =
appState.openDialog?.name === "settings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog /> <ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out /> <tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()} {renderImageExportDialog()}
@@ -1,18 +0,0 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}
@@ -1,160 +0,0 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const appState = useUIAppState();
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
if (appState.openDialog?.name !== "settings") {
return null;
}
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Wireframe to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
{/* <h2
style={{
margin: 0,
fontSize: "1.25rem",
paddingLeft: "2.5rem",
}}
>
AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited — as
such we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set
your own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need
to insert it again after reload. But, you can persist locally in
your browser below.
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to
turn it into code. This dialog can be accessed using the{" "}
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</TTDDialogTab>
</TTDDialogTabs>
</Dialog>
);
};
@@ -52,8 +52,8 @@
font-size: 0.75rem; font-size: 0.75rem;
line-height: 110%; line-height: 110%;
background: var(--color-success-lighter); background: var(--color-success);
color: var(--color-success); color: var(--color-success-text);
& > svg { & > svg {
width: 0.875rem; width: 0.875rem;
@@ -1,5 +1,4 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { TextField } from "./TextField"; import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton"; import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons"; import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { copyIcon } from "./icons";
import "./ShareableLinkDialog.scss"; import "./ShareableLinkDialog.scss";
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
setErrorMessage, setErrorMessage,
}: ShareableLinkDialogProps) => { }: ShareableLinkDialogProps) => {
const { t } = useI18n(); const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false); const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
ref.current?.select(); ref.current?.select();
}; };
const { onCopy, copyStatus } = useCopyStatus();
return ( return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small"> <Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog"> <div className="ShareableLinkDialog">
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
value={link} value={link}
selectOnRender selectOnRender
/> />
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton <FilledButton
size="large" size="large"
label="Copy link" label={t("buttons.copyLink")}
icon={copyIcon} icon={copyIcon}
onClick={copyRoomLink} status={copyStatus}
onClick={() => {
onCopy();
copyRoomLink();
}}
/> />
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div> </div>
<div className="ShareableLinkDialog__description"> <div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")} 🔒 {t("alerts.uploadedSecurly")}
+3 -1
View File
@@ -6,16 +6,18 @@ const Spinner = ({
size = "1em", size = "1em",
circleWidth = 8, circleWidth = 8,
synchronized = false, synchronized = false,
className = "",
}: { }: {
size?: string | number; size?: string | number;
circleWidth?: number; circleWidth?: number;
synchronized?: boolean; synchronized?: boolean;
className?: string;
}) => { }) => {
const mountTime = React.useRef(Date.now()); const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600); const mountDelay = -(mountTime.current % 1600);
return ( return (
<div className="Spinner"> <div className={`Spinner ${className}`}>
<svg <svg
viewBox="0 0 100 100" viewBox="0 0 100 100"
style={{ style={{
@@ -0,0 +1,67 @@
import StatsDragInput from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getStepSizedValue } from "./utils";
import { getNormalizedGridStep } from "../../scene";
interface PositionProps {
property: "gridStep";
scene: Scene;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}
const STEP_SIZE = 5;
const CanvasGrid = ({
property,
scene,
appState,
setAppState,
}: PositionProps) => {
return (
<StatsDragInput
label="Grid step"
sensitivity={8}
elements={[]}
dragInputCallback={({
nextValue,
instantChange,
shouldChangeByStepSize,
setInputValue,
}) => {
setAppState((state) => {
let nextGridStep;
if (nextValue) {
nextGridStep = nextValue;
} else if (instantChange) {
nextGridStep = shouldChangeByStepSize
? getStepSizedValue(
state.gridStep + STEP_SIZE * Math.sign(instantChange),
STEP_SIZE,
)
: state.gridStep + instantChange;
}
if (!nextGridStep) {
setInputValue(state.gridStep);
return null;
}
nextGridStep = getNormalizedGridStep(nextGridStep);
setInputValue(nextGridStep);
return {
gridStep: nextGridStep,
};
});
}}
scene={scene}
value={appState.gridStep}
property={property}
appState={appState}
/>
);
};
export default CanvasGrid;
@@ -31,7 +31,11 @@ const Collapsible = ({
{label} {label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} /> <InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div> </div>
{open && <>{children}</>} {open && (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
</div>
)}
</> </>
); );
}; };
@@ -23,7 +23,6 @@ const handleDimensionChange: DragInputCallbackType<
> = ({ > = ({
accumulatedChange, accumulatedChange,
originalElements, originalElements,
originalElementsMap,
shouldKeepAspectRatio, shouldKeepAspectRatio,
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
@@ -5,7 +5,7 @@
&:focus-within { &:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest); box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-md);
} }
} }
@@ -18,17 +18,18 @@
flex-shrink: 0; flex-shrink: 0;
border: 1px solid var(--default-border-color); border: 1px solid var(--default-border-color);
border-right: 0; border-right: 0;
width: 2rem; padding: 0 0.5rem 0 0.75rem;
min-width: 1rem;
height: 2rem; height: 2rem;
box-sizing: border-box; box-sizing: border-box;
color: var(--popup-text-color); color: var(--popup-text-color);
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
border-right: 1px solid var(--default-border-color); border-right: 1px solid var(--default-border-color);
border-left: 0; border-left: 0;
} }
@@ -55,11 +56,11 @@
letter-spacing: 0.4px; letter-spacing: 0.4px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
border-left: 1px solid var(--default-border-color); border-left: 1px solid var(--default-border-color);
border-right: 0; border-right: 0;
} }
@@ -29,6 +29,7 @@ export type DragInputCallbackType<
nextValue?: number; nextValue?: number;
property: P; property: P;
originalAppState: AppState; originalAppState: AppState;
setInputValue: (value: number) => void;
}) => void; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps<
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
property: T; property: T;
scene: Scene; scene: Scene;
appState: AppState; appState: AppState;
/** how many px you need to drag to get 1 unit change */
sensitivity?: number;
} }
const StatsDragInput = < const StatsDragInput = <
@@ -61,6 +64,7 @@ const StatsDragInput = <
property, property,
scene, scene,
appState, appState,
sensitivity = 1,
}: StatsDragInputProps<T, E>) => { }: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -126,27 +130,49 @@ const StatsDragInput = <
nextValue: rounded, nextValue: rounded,
property, property,
originalAppState: appState, originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
}); });
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
} }
}; };
const handleInputValueRef = useRef(handleInputValue); const callbacksRef = useRef<
handleInputValueRef.current = handleInputValue; Partial<{
handleInputValue: typeof handleInputValue;
onPointerUp: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
}>
>({});
callbacksRef.current.handleInputValue = handleInputValue;
// make sure that clicking on canvas (which umounts the component) // make sure that clicking on canvas (which umounts the component)
// updates current input value (blur isn't triggered) // updates current input value (blur isn't triggered)
useEffect(() => { useEffect(() => {
const input = inputRef.current; const input = inputRef.current;
const callbacks = callbacksRef.current;
return () => { return () => {
const nextValue = input?.value; const nextValue = input?.value;
if (nextValue) { if (nextValue) {
handleInputValueRef.current( callbacks.handleInputValue?.(
nextValue, nextValue,
stateRef.current.originalElements, stateRef.current.originalElements,
stateRef.current.originalAppState, stateRef.current.originalAppState,
); );
} }
// generally not needed, but in case `pointerup` doesn't fire and
// we don't remove the listeners that way, we should at least remove
// on unmount
window.removeEventListener(
EVENT.POINTER_MOVE,
callbacks.onPointerMove!,
false,
);
window.removeEventListener(
EVENT.POINTER_UP,
callbacks.onPointerUp!,
false,
);
}; };
}, [ }, [
// we need to track change of `editable` state as mount/unmount // we need to track change of `editable` state as mount/unmount
@@ -172,6 +198,8 @@ const StatsDragInput = <
ref={labelRef} ref={labelRef}
onPointerDown={(event) => { onPointerDown={(event) => {
if (inputRef.current && editable) { if (inputRef.current && editable) {
document.body.classList.add("excalidraw-cursor-resize");
let startValue = Number(inputRef.current.value); let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) { if (isNaN(startValue)) {
startValue = 0; startValue = 0;
@@ -196,27 +224,30 @@ const StatsDragInput = <
const originalAppState: AppState = cloneJSON(appState); const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null; let accumulatedChange = 0;
let stepChange = 0;
document.body.classList.add("excalidraw-cursor-resize");
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if ( if (
lastPointer && lastPointer &&
originalElementsMap !== null && originalElementsMap !== null &&
originalElements !== null && originalElements !== null
accumulatedChange !== null
) { ) {
const instantChange = event.clientX - lastPointer.x; const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
if (instantChange !== 0) {
stepChange += instantChange;
if (Math.abs(stepChange) >= sensitivity) {
stepChange =
Math.sign(stepChange) *
Math.floor(Math.abs(stepChange) / sensitivity);
accumulatedChange += stepChange;
dragInputCallback({ dragInputCallback({
accumulatedChange, accumulatedChange,
instantChange, instantChange: stepChange,
originalElements, originalElements,
originalElementsMap, originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldKeepAspectRatio: shouldKeepAspectRatio!!,
@@ -224,7 +255,12 @@ const StatsDragInput = <
property, property,
scene, scene,
originalAppState, originalAppState,
setInputValue: (value) => setInputValue(String(value)),
}); });
stepChange = 0;
}
}
} }
lastPointer = { lastPointer = {
@@ -233,10 +269,7 @@ const StatsDragInput = <
}; };
}; };
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false); const onPointerUp = () => {
window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener( window.removeEventListener(
EVENT.POINTER_MOVE, EVENT.POINTER_MOVE,
onPointerMove, onPointerMove,
@@ -246,14 +279,21 @@ const StatsDragInput = <
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null; lastPointer = null;
accumulatedChange = null; accumulatedChange = 0;
stepChange = 0;
originalElements = null; originalElements = null;
originalElementsMap = null; originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize"); document.body.classList.remove("excalidraw-cursor-resize");
},
false, window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
); };
callbacksRef.current.onPointerMove = onPointerMove;
callbacksRef.current.onPointerUp = onPointerUp;
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
} }
}} }}
onPointerEnter={() => { onPointerEnter={() => {
@@ -0,0 +1,72 @@
.exc-stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
h2 {
font-size: 1.5em;
margin-block-start: 0.83em;
margin-block-end: 0.83em;
font-weight: bold;
}
h3 {
white-space: nowrap;
font-size: 1.17em;
margin: 0;
font-weight: bold;
}
&__rows {
display: flex;
flex-direction: column;
gap: 0.3125rem;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
gap: 4px;
div + div {
text-align: right;
}
}
&__row--heading {
text-align: center;
font-weight: bold;
margin: 0.25rem 0;
}
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
}
+132 -44
View File
@@ -2,13 +2,16 @@ import { useEffect, useMemo, useState, memo } from "react";
import { getCommonBounds } from "../../element/bounds"; import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import type { AppState, ExcalidrawProps } from "../../types"; import type {
AppClassProperties,
AppState,
ExcalidrawProps,
} from "../../types";
import { CloseIcon } from "../icons"; import { CloseIcon } from "../icons";
import { Island } from "../Island"; import { Island } from "../Island";
import { throttle } from "lodash"; import { throttle } from "lodash";
import Dimension from "./Dimension"; import Dimension from "./Dimension";
import Angle from "./Angle"; import Angle from "./Angle";
import FontSize from "./FontSize"; import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension"; import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups"; import { elementsAreInSameGroup } from "../../groups";
@@ -17,14 +20,18 @@ import MultiFontSize from "./MultiFontSize";
import Position from "./Position"; import Position from "./Position";
import MultiPosition from "./MultiPosition"; import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible"; import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks"; import { isElbowArrow } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
interface StatsProps { interface StatsProps {
scene: Scene; app: AppClassProperties;
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
} }
@@ -33,11 +40,12 @@ const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => { export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const sceneNonce = props.scene.getSceneNonce() || 1; const sceneNonce = props.app.scene.getSceneNonce() || 1;
const selectedElements = props.scene.getSelectedElements({ const selectedElements = props.app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
const gridModeEnabled = isGridModeEnabled(props.app);
return ( return (
<StatsInner <StatsInner
@@ -45,23 +53,71 @@ export const Stats = (props: StatsProps) => {
appState={appState} appState={appState}
sceneNonce={sceneNonce} sceneNonce={sceneNonce}
selectedElements={selectedElements} selectedElements={selectedElements}
gridModeEnabled={gridModeEnabled}
/> />
); );
}; };
const StatsRow = ({
children,
columns = 1,
heading,
style,
...rest
}: {
children: React.ReactNode;
columns?: number;
heading?: boolean;
style?: React.CSSProperties;
} & React.HTMLAttributes<HTMLDivElement>) => (
<div
className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
...style,
}}
{...rest}
>
{children}
</div>
);
StatsRow.displayName = "StatsRow";
const StatsRows = ({
children,
order,
style,
...rest
}: {
children: React.ReactNode;
order?: number;
style?: React.CSSProperties;
} & React.HTMLAttributes<HTMLDivElement>) => (
<div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
{children}
</div>
);
StatsRows.displayName = "StatsRows";
Stats.StatsRow = StatsRow;
Stats.StatsRows = StatsRows;
export const StatsInner = memo( export const StatsInner = memo(
({ ({
scene, app,
onClose, onClose,
renderCustomStats, renderCustomStats,
selectedElements, selectedElements,
appState, appState,
sceneNonce, sceneNonce,
gridModeEnabled,
}: StatsProps & { }: StatsProps & {
sceneNonce: number; sceneNonce: number;
selectedElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
gridModeEnabled: boolean;
}) => { }) => {
const scene = app.scene;
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
@@ -106,7 +162,7 @@ export const StatsInner = memo(
}, [selectedElements, appState]); }, [selectedElements, appState]);
return ( return (
<div className="Stats"> <div className="exc-stats">
<Island padding={3}> <Island padding={3}>
<div className="title"> <div className="title">
<h2>{t("stats.title")}</h2> <h2>{t("stats.title")}</h2>
@@ -121,7 +177,6 @@ export const StatsInner = memo(
openTrigger={() => openTrigger={() =>
setAppState((state) => { setAppState((state) => {
return { return {
...state,
stats: { stats: {
open: true, open: true,
panels: state.stats.panels ^ STATS_PANELS.generalStats, panels: state.stats.panels ^ STATS_PANELS.generalStats,
@@ -130,26 +185,36 @@ export const StatsInner = memo(
}) })
} }
> >
<table> <StatsRows>
<tbody> <StatsRow heading>{t("stats.scene")}</StatsRow>
<tr> <StatsRow columns={2}>
<th colSpan={2}>{t("stats.scene")}</th> <div>{t("stats.shapes")}</div>
</tr> <div>{elements.length}</div>
<tr> </StatsRow>
<td>{t("stats.elements")}</td> <StatsRow columns={2}>
<td>{elements.length}</td> <div>{t("stats.width")}</div>
</tr> <div>{sceneDimension.width}</div>
<tr> </StatsRow>
<td>{t("stats.width")}</td> <StatsRow columns={2}>
<td>{sceneDimension.width}</td> <div>{t("stats.height")}</div>
</tr> <div>{sceneDimension.height}</div>
<tr> </StatsRow>
<td>{t("stats.height")}</td> {gridModeEnabled && (
<td>{sceneDimension.height}</td> <>
</tr> <StatsRow heading>Canvas</StatsRow>
<StatsRow>
<CanvasGrid
property="gridStep"
scene={scene}
appState={appState}
setAppState={setAppState}
/>
</StatsRow>
</>
)}
</StatsRows>
{renderCustomStats?.(elements, appState)} {renderCustomStats?.(elements, appState)}
</tbody>
</table>
</Collapsible> </Collapsible>
{selectedElements.length > 0 && ( {selectedElements.length > 0 && (
@@ -167,7 +232,6 @@ export const StatsInner = memo(
openTrigger={() => openTrigger={() =>
setAppState((state) => { setAppState((state) => {
return { return {
...state,
stats: { stats: {
open: true, open: true,
panels: panels:
@@ -177,13 +241,14 @@ export const StatsInner = memo(
}) })
} }
> >
<StatsRows>
{singleElement && ( {singleElement && (
<div className="sectionContent"> <>
<div className="elementType"> <StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)} {t(`element.${singleElement.type}`)}
</div> </StatsRow>
<div className="statsItem"> <StatsRow>
<Position <Position
element={singleElement} element={singleElement}
property="x" property="x"
@@ -191,6 +256,8 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<Position <Position
element={singleElement} element={singleElement}
property="y" property="y"
@@ -198,48 +265,56 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<Dimension <Dimension
property="width" property="width"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<Dimension <Dimension
property="height" property="height"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
{!isElbowArrow(singleElement) && ( {!isElbowArrow(singleElement) && (
<StatsRow>
<Angle <Angle
property="angle" property="angle"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
)} )}
<StatsRow>
<FontSize <FontSize
property="fontSize" property="fontSize"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</div> </StatsRow>
</div> </>
)} )}
{multipleElements && ( {multipleElements && (
<div className="sectionContent"> <>
{elementsAreInSameGroup(multipleElements) && ( {elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div> <StatsRow heading>{t("element.group")}</StatsRow>
)} )}
<div className="elementsCount"> <StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
<div>{t("stats.elements")}</div> <div>{t("stats.shapes")}</div>
<div>{selectedElements.length}</div> <div>{selectedElements.length}</div>
</div> </StatsRow>
<div className="statsItem"> <StatsRow>
<MultiPosition <MultiPosition
property="x" property="x"
elements={multipleElements} elements={multipleElements}
@@ -248,6 +323,8 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<MultiPosition <MultiPosition
property="y" property="y"
elements={multipleElements} elements={multipleElements}
@@ -256,6 +333,8 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<MultiDimension <MultiDimension
property="width" property="width"
elements={multipleElements} elements={multipleElements}
@@ -264,6 +343,8 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<MultiDimension <MultiDimension
property="height" property="height"
elements={multipleElements} elements={multipleElements}
@@ -272,12 +353,16 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<MultiAngle <MultiAngle
property="angle" property="angle"
elements={multipleElements} elements={multipleElements}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
<StatsRow>
<MultiFontSize <MultiFontSize
property="fontSize" property="fontSize"
elements={multipleElements} elements={multipleElements}
@@ -285,9 +370,10 @@ export const StatsInner = memo(
appState={appState} appState={appState}
elementsMap={elementsMap} elementsMap={elementsMap}
/> />
</div> </StatsRow>
</div> </>
)} )}
</StatsRows>
</Collapsible> </Collapsible>
</div> </div>
)} )}
@@ -299,7 +385,9 @@ export const StatsInner = memo(
return ( return (
prev.sceneNonce === next.sceneNonce && prev.sceneNonce === next.sceneNonce &&
prev.selectedElements === next.selectedElements && prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
); );
}, },
); );
@@ -32,21 +32,6 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null; let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null; let elementStats: HTMLElement | null | undefined = null;
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
const testInputProperty = ( const testInputProperty = (
element: ExcalidrawElement, element: ExcalidrawElement,
property: "x" | "y" | "width" | "height" | "angle" | "fontSize", property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
@@ -54,7 +39,7 @@ const testInputProperty = (
initialValue: number, initialValue: number,
nextValue: number, nextValue: number,
) => { ) => {
const input = getStatsProperty(label)?.querySelector( const input = UI.queryStatsProperty(label)?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -136,7 +121,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small position change", async () => { it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -148,7 +133,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => { it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -159,7 +144,7 @@ describe("binding with linear elements", () => {
it("should unbind linear element on large position change", async () => { it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -171,7 +156,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => { it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -225,18 +210,14 @@ describe("stats for a generic element", () => {
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties")); expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
// element type // element type
const elementType = elementStats?.querySelector(".elementType"); const elementType = queryByTestId(elementStats!, "stats-element-type");
expect(elementType).toBeDefined(); expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle")); expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties // properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => { ["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect( expect(
properties?.querySelector?.( stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
`.drag-input-container[data-testid="${label}"]`,
),
).toBeDefined(); ).toBeDefined();
}); });
}); });
@@ -257,7 +238,7 @@ describe("stats for a generic element", () => {
const rectangle = h.elements[0]; const rectangle = h.elements[0];
const rectangleId = rectangle.id; const rectangleId = rectangle.id;
const input = getStatsProperty("W")?.querySelector( const input = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -287,11 +268,11 @@ describe("stats for a generic element", () => {
rectangle.angle, rectangle.angle,
); );
const xInput = getStatsProperty("X")?.querySelector( const xInput = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
const yInput = getStatsProperty("Y")?.querySelector( const yInput = UI.queryStatsProperty("Y")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -417,7 +398,7 @@ describe("stats for a non-generic element", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
// can change font size // can change font size
const input = getStatsProperty("F")?.querySelector( const input = UI.queryStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -426,9 +407,9 @@ describe("stats for a non-generic element", () => {
expect(text.fontSize).toBe(36); expect(text.fontSize).toBe(36);
// cannot change width or height // cannot change width or height
const width = getStatsProperty("W")?.querySelector(".drag-input"); const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined(); expect(width).toBeUndefined();
const height = getStatsProperty("H")?.querySelector(".drag-input"); const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined(); expect(height).toBeUndefined();
// min font size is 4 // min font size is 4
@@ -456,7 +437,7 @@ describe("stats for a non-generic element", () => {
expect(elementStats).toBeDefined(); expect(elementStats).toBeDefined();
// cannot change angle // cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input"); const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined(); expect(angle).toBeUndefined();
// can change width or height // can change width or height
@@ -506,7 +487,7 @@ describe("stats for a non-generic element", () => {
API.setElements([container, text]); API.setElements([container, text]);
API.setSelectedElements([container]); API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector( const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
@@ -570,15 +551,15 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector( const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width?.value).toBe("Mixed"); expect(width?.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector( const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height?.value).toBe("Mixed"); expect(height?.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector( const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
@@ -629,25 +610,25 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector( const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width).toBeDefined(); expect(width).toBeDefined();
expect(width.value).toBe("Mixed"); expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector( const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).toBeDefined(); expect(height).toBeDefined();
expect(height.value).toBe("Mixed"); expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector( const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle).toBeDefined(); expect(angle).toBeDefined();
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector( const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
@@ -692,7 +673,7 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const x = getStatsProperty("X")?.querySelector( const x = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -705,7 +686,7 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].x).toBe(400); expect(h.elements[1].x).toBe(400);
expect(x.value).toBe("300"); expect(x.value).toBe("300");
const y = getStatsProperty("Y")?.querySelector( const y = UI.queryStatsProperty("Y")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -718,13 +699,13 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].y).toBe(300); expect(h.elements[1].y).toBe(300);
expect(y.value).toBe("200"); expect(y.value).toBe("200");
const width = getStatsProperty("W")?.querySelector( const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width).toBeDefined(); expect(width).toBeDefined();
expect(Number(width.value)).toBe(200); expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector( const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).toBeDefined(); expect(height).toBeDefined();
@@ -41,7 +41,8 @@ export type StatsInputProperty =
| "width" | "width"
| "height" | "height"
| "angle" | "angle"
| "fontSize"; | "fontSize"
| "gridStep";
export const SMALLEST_DELTA = 0.01; export const SMALLEST_DELTA = 0.01;
@@ -7,10 +7,7 @@ import { isMemberOf } from "../../utils";
const TTDDialogTabs = ( const TTDDialogTabs = (
props: { props: {
children: ReactNode; children: ReactNode;
} & ( } & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
),
) => { ) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
@@ -39,13 +36,6 @@ const TTDDialogTabs = (
} }
} }
if ( if (
props.dialog === "settings" &&
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab, source: "settings" },
});
} else if (
props.dialog === "ttd" && props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid"], tab) isMemberOf(["text-to-diagram", "mermaid"], tab)
) { ) {
@@ -202,7 +202,7 @@ const getRelevantAppStateProps = (
activeEmbeddable: appState.activeEmbeddable, activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingElement: appState.editingElement, editingTextElement: appState.editingTextElement,
}); });
const areEqual = ( const areEqual = (
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import type { NonDeletedSceneElementsMap } from "../../element/types";
import type { AppState } from "../../types";
import type {
RenderableElementsMap,
StaticCanvasRenderConfig,
} from "../../scene/types";
import type { RoughCanvas } from "roughjs/bin/canvas";
import { renderNewElementScene } from "../../renderer/renderNewElementScene";
import { isRenderThrottlingEnabled } from "../../reactUtils";
interface NewElementCanvasProps {
appState: AppState;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
rc: RoughCanvas;
renderConfig: StaticCanvasRenderConfig;
}
const NewElementCanvas = (props: NewElementCanvasProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!canvasRef.current) {
return;
}
renderNewElementScene(
{
canvas: canvasRef.current,
scale: props.scale,
newElement: props.appState.newElement,
elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap,
rc: props.rc,
renderConfig: props.renderConfig,
appState: props.appState,
},
isRenderThrottlingEnabled(),
);
});
return (
<canvas
className="excalidraw__canvas"
style={{
width: props.appState.width,
height: props.appState.height,
}}
width={props.appState.width * props.scale}
height={props.appState.height * props.scale}
ref={canvasRef}
/>
);
};
export default NewElementCanvas;
@@ -101,6 +101,7 @@ const getRelevantAppStateProps = (
exportScale: appState.exportScale, exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize, gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering, frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
@@ -13,6 +13,7 @@ const DropdownMenuItemLink = ({
onSelect, onSelect,
className = "", className = "",
selected, selected,
rel = "noreferrer",
...rest ...rest
}: { }: {
href: string; href: string;
@@ -22,6 +23,7 @@ const DropdownMenuItemLink = ({
className?: string; className?: string;
selected?: boolean; selected?: boolean;
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
rel?: string;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => { } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+3 -2
View File
@@ -179,7 +179,8 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const DEFAULT_GRID_SIZE = 20;
export const DEFAULT_GRID_STEP = 5;
export const IMAGE_MIME_TYPES = { export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml", svg: "image/svg+xml",
@@ -234,7 +235,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1; export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0; export const MAX_ZOOM = 30;
export const HYPERLINK_TOOLTIP_DELAY = 300; export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
+8 -2
View File
@@ -129,8 +129,14 @@
--color-muted-background-darker: var(--color-gray-100); --color-muted-background-darker: var(--color-gray-100);
--color-promo: var(--color-primary); --color-promo: var(--color-primary);
--color-success: #268029;
--color-success-lighter: #cafccc; --color-success: #cafccc;
--color-success-darker: #bafabc;
--color-success-darkest: #a5eba8;
--color-success-text: #268029;
--color-success-contrast: #65bb6a;
--color-success-contrast-hover: #6bcf70;
--color-success-contrast-active: #6edf74;
--color-logo-icon: var(--color-primary); --color-logo-icon: var(--color-primary);
--color-logo-text: #190064; --color-logo-text: #190064;
-105
View File
@@ -1,105 +0,0 @@
import { THEME } from "../constants";
import type { Theme } from "../element/types";
import type { DataURL } from "../types";
import type { OpenAIInput, OpenAIOutput } from "./ai/types";
export type MagicCacheData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
Your role is to transform low-fidelity wireframes into working front-end HTML code.
YOU MUST FOLLOW FOLLOWING RULES:
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
- Inline JavaScript when needed
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
- Source images from Unsplash or create applicable placeholders
- Interpret annotations as intended vs literal UI
- Fill gaps using your expertise in UX and business logic
- generate primarily for desktop UI, but make it responsive.
- Use grid and flexbox wherever applicable.
- Convert the wireframe in its entirety, don't omit elements if possible.
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
Your goal is a production-ready prototype that brings the wireframes to life.
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
export async function diagramToHTML({
image,
apiKey,
text,
theme = THEME.LIGHT,
}: {
image: DataURL;
apiKey: string;
text: string;
theme?: Theme;
}) {
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
model: "gpt-4-vision-preview",
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
max_tokens: 4096,
temperature: 0.1,
messages: [
{
role: "system",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: image,
detail: "high",
},
},
{
type: "text",
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
},
{
type: "text",
text,
},
],
},
],
};
let result:
| ({ ok: true } & OpenAIOutput.ChatCompletion)
| ({ ok: false } & OpenAIOutput.APIError);
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (resp.ok) {
const json: OpenAIOutput.ChatCompletion = await resp.json();
result = { ...json, ok: true };
} else {
const json: OpenAIOutput.APIError = await resp.json();
result = { ...json, ok: false };
}
return result;
}
+1 -1
View File
@@ -24,7 +24,7 @@ const shouldDiscardRemoteElement = (
if ( if (
local && local &&
// local element is being edited // local element is being edited
(local.id === localAppState.editingElement?.id || (local.id === localAppState.editingTextElement?.id ||
local.id === localAppState.resizingElement?.id || local.id === localAppState.resizingElement?.id ||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
// local element is newer // local element is newer
+25 -14
View File
@@ -10,12 +10,7 @@ import type {
PointBinding, PointBinding,
StrokeRoundness, StrokeRoundness,
} from "../element/types"; } from "../element/types";
import type { import type { AppState, BinaryFiles, LibraryItem } from "../types";
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import type { ImportedDataState, LegacyAppState } from "./types"; import type { ImportedDataState, LegacyAppState } from "./types";
import { import {
getNonDeletedElements, getNonDeletedElements,
@@ -39,11 +34,17 @@ import {
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_GRID_SIZE,
DEFAULT_GRID_STEP,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import {
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement"; import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -52,6 +53,11 @@ import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { normalizeFixedPoint } from "../element/binding"; import { normalizeFixedPoint } from "../element/binding";
import {
getNormalizedGridSize,
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -614,19 +620,24 @@ export const restoreAppState = (
locked: nextAppState.activeTool.locked ?? false, locked: nextAppState.activeTool.locked ?? false,
}, },
// Migrates from previous version where appState.zoom was a number // Migrates from previous version where appState.zoom was a number
zoom: zoom: {
typeof appState.zoom === "number" value: getNormalizedZoom(
? { isFiniteNumber(appState.zoom)
value: appState.zoom as NormalizedZoomValue,
}
: appState.zoom?.value
? appState.zoom ? appState.zoom
: defaultAppState.zoom, : appState.zoom?.value ?? defaultAppState.zoom.value,
),
},
openSidebar: openSidebar:
// string (legacy) // string (legacy)
typeof (appState.openSidebar as any as string) === "string" typeof (appState.openSidebar as any as string) === "string"
? { name: DEFAULT_SIDEBAR.name } ? { name: DEFAULT_SIDEBAR.name }
: nextAppState.openSidebar, : nextAppState.openSidebar,
gridSize: getNormalizedGridSize(
isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
),
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
}; };
}; };
+11 -13
View File
@@ -41,6 +41,7 @@ import {
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -71,6 +72,7 @@ import {
vectorToHeading, vectorToHeading,
type Heading, type Heading,
} from "./heading"; } from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@@ -751,7 +753,8 @@ export const bindPointToSnapToElementOutline = (
const aabb = bindableElement && aabbForElement(bindableElement); const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) { if (bindableElement && aabb) {
// TODO: Dirty hack until tangents are properly calculated // TODO: Dirty hacks until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point);
const intersections = [ const intersections = [
...intersectElementWithLine( ...intersectElementWithLine(
bindableElement, bindableElement,
@@ -759,11 +762,6 @@ export const bindPointToSnapToElementOutline = (
[point[0], point[1] + 2 * bindableElement.height], [point[0], point[1] + 2 * bindableElement.height],
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
).map((i) =>
distanceToBindableElement(bindableElement, i, elementsMap) >=
bindableElement.height / 2
? ([point[0], -1 * i[1]] as Point)
: ([point[0], i[1]] as Point),
), ),
...intersectElementWithLine( ...intersectElementWithLine(
bindableElement, bindableElement,
@@ -771,19 +769,15 @@ export const bindPointToSnapToElementOutline = (
[point[0] + 2 * bindableElement.width, point[1]], [point[0] + 2 * bindableElement.width, point[1]],
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
).map((i) =>
distanceToBindableElement(bindableElement, i, elementsMap) >=
bindableElement.width / 2
? ([-1 * i[0], point[1]] as Point)
: ([i[0], point[1]] as Point),
), ),
]; ];
const heading = headingForPointFromElement(bindableElement, aabb, point);
const isVertical = const isVertical =
compareHeading(heading, HEADING_LEFT) || compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT); compareHeading(heading, HEADING_RIGHT);
const dist = distanceToBindableElement(bindableElement, point, elementsMap); const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap),
);
const isInner = isVertical const isInner = isVertical
? dist < bindableElement.width * -0.1 ? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1; : dist < bindableElement.height * -0.1;
@@ -1610,6 +1604,10 @@ const intersectElementWithLine = (
gap: number = 0, gap: number = 0,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point[] => { ): Point[] => {
if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap);
}
const relateToCenter = relativizationToElementCenter(element, elementsMap); const relateToCenter = relativizationToElementCenter(element, elementsMap);
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
+3 -2
View File
@@ -738,6 +738,7 @@ export const getElementBounds = (
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
@@ -748,10 +749,10 @@ export const getCommonBounds = (
let minY = Infinity; let minY = Infinity;
let maxY = -Infinity; let maxY = -Infinity;
const elementsMap = arrayToMap(elements); const _elementsMap = elementsMap || arrayToMap(elements);
elements.forEach((element) => { elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap);
minX = Math.min(minX, x1); minX = Math.min(minX, x1);
minY = Math.min(minY, y1); minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2); maxX = Math.max(maxX, x2);
+48 -23
View File
@@ -4,7 +4,12 @@ import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types"; import type { NonDeletedExcalidrawElement } from "./types";
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types"; import type {
AppState,
NormalizedZoomValue,
NullableGridSize,
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math"; import { getGridPoint } from "../math";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
@@ -26,7 +31,7 @@ export const dragSelectedElements = (
x: number; x: number;
y: number; y: number;
}, },
gridSize: AppState["gridSize"], gridSize: NullableGridSize,
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
@@ -101,7 +106,7 @@ const calculateOffset = (
commonBounds: Bounds, commonBounds: Bounds,
dragOffset: { x: number; y: number }, dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number }, snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"], gridSize: NullableGridSize,
): { x: number; y: number } => { ): { x: number; y: number } => {
const [x, y] = commonBounds; const [x, y] = commonBounds;
let nextX = x + dragOffset.x + snapOffset.x; let nextX = x + dragOffset.x + snapOffset.x;
@@ -154,26 +159,42 @@ export const getDragOffsetXY = (
return [x - x1, y - y1]; return [x - x1, y - y1];
}; };
export const dragNewElement = ( export const dragNewElement = ({
newElement: NonDeletedExcalidrawElement, newElement,
elementType: AppState["activeTool"]["type"], elementType,
originX: number, originX,
originY: number, originY,
x: number, x,
y: number, y,
width: number, width,
height: number, height,
shouldMaintainAspectRatio: boolean, shouldMaintainAspectRatio,
shouldResizeFromCenter: boolean, shouldResizeFromCenter,
zoom: NormalizedZoomValue, zoom,
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is widthAspectRatio = null,
true */ originOffset = null,
widthAspectRatio?: number | null, informMutation = true,
originOffset: { }: {
newElement: NonDeletedExcalidrawElement;
elementType: AppState["activeTool"]["type"];
originX: number;
originY: number;
x: number; x: number;
y: number; y: number;
} | null = null, width: number;
) => { height: number;
shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null;
originOffset?: {
x: number;
y: number;
} | null;
informMutation?: boolean;
}) => {
if (shouldMaintainAspectRatio && newElement.type !== "selection") { if (shouldMaintainAspectRatio && newElement.type !== "selection") {
if (widthAspectRatio) { if (widthAspectRatio) {
height = width / widthAspectRatio; height = width / widthAspectRatio;
@@ -237,12 +258,16 @@ export const dragNewElement = (
} }
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
mutateElement(newElement, { mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0), x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0), y: newY + (originOffset?.y ?? 0),
width, width,
height, height,
...textAutoResize, ...textAutoResize,
}); },
informMutation,
);
} }
}; };
@@ -36,7 +36,7 @@ describe("flow chart creation", () => {
height: 100, height: 100,
}); });
h.elements = [rectangle]; API.setElements([rectangle]);
API.setSelectedElements([rectangle]); API.setSelectedElements([rectangle]);
}); });
@@ -166,7 +166,7 @@ describe("flow chart navigation", () => {
height: 100, height: 100,
}); });
h.elements = [rectangle]; API.setElements([rectangle]);
API.setSelectedElements([rectangle]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -234,7 +234,7 @@ describe("flow chart navigation", () => {
height: 100, height: 100,
}); });
h.elements = [rectangle]; API.setElements([rectangle]);
API.setSelectedElements([rectangle]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -325,7 +325,7 @@ describe("flow chart navigation", () => {
height: 100, height: 100,
}); });
h.elements = [rectangle]; API.setElements([rectangle]);
API.setSelectedElements([rectangle]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
+1 -1
View File
@@ -46,7 +46,7 @@ export {
dragNewElement, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks"; export { isTextElement, isExcalidrawElement } from "./typeChecks";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
export { export {
getPerfectElementSize, getPerfectElementSize,
getLockedLinearCursorAlignSize, getLockedLinearCursorAlignSize,
@@ -36,6 +36,8 @@ import type {
AppState, AppState,
PointerCoords, PointerCoords,
InteractiveCanvasAppState, InteractiveCanvasAppState,
AppClassProperties,
NullableGridSize,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -209,7 +211,7 @@ export class LinearElementEditor {
/** @returns whether point was dragged */ /** @returns whether point was dragged */
static handlePointDragging( static handlePointDragging(
event: PointerEvent, event: PointerEvent,
appState: AppState, app: AppClassProperties,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: ( maybeSuggestBinding: (
@@ -279,7 +281,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
referencePoint, referencePoint,
[scenePointerX, scenePointerY], [scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -299,7 +301,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
@@ -315,7 +317,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
) )
: ([ : ([
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
@@ -695,7 +697,7 @@ export class LinearElementEditor {
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
appState: AppState, app: AppClassProperties,
store: Store, store: Store,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
@@ -705,6 +707,7 @@ export class LinearElementEditor {
hitElement: NonDeleted<ExcalidrawElement> | null; hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null; linearElementEditor: LinearElementEditor | null;
} { } {
const appState = app.state;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
@@ -752,7 +755,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
), ),
], ],
}); });
@@ -876,9 +879,10 @@ export class LinearElementEditor {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
appState: AppState, app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null { ): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return null; return null;
} }
@@ -915,7 +919,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
lastCommittedPoint, lastCommittedPoint,
[scenePointerX, scenePointerY], [scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
newPoint = [ newPoint = [
@@ -930,7 +934,7 @@ export class LinearElementEditor {
scenePointerY - appState.editingLinearElement.pointerOffset.y, scenePointerY - appState.editingLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null ? null
: appState.gridSize, : app.getEffectiveGridSize(),
); );
} }
@@ -1065,7 +1069,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap, elementsMap: ElementsMap,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
gridSize: number | null, gridSize: NullableGridSize,
): Point { ): Point {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -1363,7 +1367,7 @@ export class LinearElementEditor {
static addMidpoint( static addMidpoint(
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords, pointerCoords: PointerCoords,
appState: AppState, app: AppClassProperties,
snapToGrid: boolean, snapToGrid: boolean,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ) {
@@ -1388,7 +1392,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
snapToGrid && !isElbowArrow(element) ? appState.gridSize : null, snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
); );
const points = [ const points = [
...element.points.slice(0, segmentMidpoint.index!), ...element.points.slice(0, segmentMidpoint.index!),
@@ -1485,7 +1489,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap, elementsMap: ElementsMap,
referencePoint: Point, referencePoint: Point,
scenePointer: Point, scenePointer: Point,
gridSize: number | null, gridSize: NullableGridSize,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
+2 -1
View File
@@ -375,12 +375,13 @@ export const newFreeDrawElement = (
type: "freedraw"; type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"]; points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean; simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
return { return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
pressures: [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null, lastCommittedPoint: null,
}; };
+1 -16
View File
@@ -22,21 +22,6 @@ const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
describe("elbow arrow routing", () => { describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => { it("can properly generate orthogonal arrow points", () => {
const scene = new Scene(); const scene = new Scene();
@@ -193,7 +178,7 @@ describe("elbow arrow ui", () => {
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
UI.updateInput(inputAngle, String("40")); UI.updateInput(inputAngle, String("40"));
@@ -9,7 +9,7 @@ export const showSelectedShapeActions = (
Boolean( Boolean(
!appState.viewModeEnabled && !appState.viewModeEnabled &&
((appState.activeTool.type !== "custom" && ((appState.activeTool.type !== "custom" &&
(appState.editingElement || (appState.editingTextElement ||
(appState.activeTool.type !== "selection" && (appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" && appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" && appState.activeTool.type !== "hand" &&
+13 -7
View File
@@ -3,7 +3,7 @@ import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types"; import type { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted // TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
@@ -56,7 +56,7 @@ export const isElementInViewport = (
}; };
export const isElementCompletelyInViewport = ( export const isElementCompletelyInViewport = (
element: ExcalidrawElement, elements: ExcalidrawElement[],
width: number, width: number,
height: number, height: number,
viewTransformations: { viewTransformations: {
@@ -67,19 +67,25 @@ export const isElementCompletelyInViewport = (
scrollY: number; scrollY: number;
}, },
elementsMap: ElementsMap, elementsMap: ElementsMap,
padding?: Partial<{
top: number;
right: number;
bottom: number;
left: number;
}>,
) => { ) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords( const topLeftSceneCoords = viewportCoordsToSceneCoords(
{ {
clientX: viewTransformations.offsetLeft, clientX: viewTransformations.offsetLeft + (padding?.left || 0),
clientY: viewTransformations.offsetTop, clientY: viewTransformations.offsetTop + (padding?.top || 0),
}, },
viewTransformations, viewTransformations,
); );
const bottomRightSceneCoords = viewportCoordsToSceneCoords( const bottomRightSceneCoords = viewportCoordsToSceneCoords(
{ {
clientX: viewTransformations.offsetLeft + width, clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
clientY: viewTransformations.offsetTop + height, clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
}, },
viewTransformations, viewTransformations,
); );
@@ -886,3 +886,19 @@ export const getMinTextElementWidth = (
) => { ) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
}; };
/** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = (
elements: readonly ExcalidrawElement[],
separator = "\n\n",
) => {
const text = elements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join(separator);
return text;
};
@@ -61,9 +61,9 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect( expect(
(h.state.editingElement as ExcalidrawTextElement).containerId, (h.state.editingTextElement as ExcalidrawTextElement).containerId,
).toBe(null); ).toBe(null);
}); });
@@ -105,7 +105,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(boundText2.id); expect(h.state.editingTextElement?.id).toBe(boundText2.id);
}); });
it("should not create bound text on ENTER if text exists at container center", () => { it("should not create bound text on ENTER if text exists at container center", () => {
@@ -133,7 +133,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
}); });
it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => { it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
@@ -174,7 +174,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(boundText.id); expect(h.state.editingTextElement?.id).toBe(boundText.id);
}); });
it("should edit text under cursor when clicked with text tool", async () => { it("should edit text under cursor when clicked with text tool", async () => {
@@ -195,7 +195,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, false); const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
@@ -217,7 +217,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, false); const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
@@ -286,7 +286,7 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
const editor = await getTextEditor(textEditorSelector); const editor = await getTextEditor(textEditorSelector);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
const nextText = `${wrappedText} is great!`; const nextText = `${wrappedText} is great!`;
@@ -881,7 +881,7 @@ describe("textWysiwyg", () => {
expect(await getTextEditor(textEditorSelector, false)).toBe(null); expect(await getTextEditor(textEditorSelector, false)).toBe(null);
expect(h.state.editingElement).toBe(null); expect(h.state.editingTextElement).toBe(null);
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
+10 -1
View File
@@ -357,7 +357,16 @@ export const textWysiwyg = ({
}; };
editable.oninput = () => { editable.oninput = () => {
onChange(normalizeText(editable.value)); const normalized = normalizeText(editable.value);
if (editable.value !== normalized) {
const selectionStart = editable.selectionStart;
editable.value = normalized;
// put the cursor at some position close to where it was before
// normalization (otherwise it'll end up at the end of the text)
editable.selectionStart = selectionStart;
editable.selectionEnd = selectionStart;
}
onChange(editable.value);
}; };
} }
+17
View File
@@ -176,6 +176,23 @@ export const isRectanguloidElement = (
); );
}; };
// TODO: Remove this when proper distance calculation is introduced
// @see binding.ts:distanceToBindableElement()
export const isRectangularElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "text" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe")
);
};
export const isTextBindableContainer = ( export const isTextBindableContainer = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
+12 -2
View File
@@ -12,7 +12,6 @@ import type {
Merge, Merge,
ValueOf, ValueOf,
} from "../utility-types"; } from "../utility-types";
import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@@ -101,11 +100,22 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
type: "embeddable"; type: "embeddable";
}>; }>;
export type MagicGenerationData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
export type ExcalidrawIframeElement = _ExcalidrawElementBase & export type ExcalidrawIframeElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "iframe"; type: "iframe";
// TODO move later to AI-specific frame // TODO move later to AI-specific frame
customData?: { generationData?: MagicCacheData }; customData?: { generationData?: MagicGenerationData };
}>; }>;
export type ExcalidrawIframeLikeElement = export type ExcalidrawIframeLikeElement =
@@ -0,0 +1,22 @@
import { useRef, useState } from "react";
const TIMEOUT = 2000;
export const useCopyStatus = () => {
const [copyStatus, setCopyStatus] = useState<"success" | null>(null);
const timeoutRef = useRef<number>(0);
const onCopy = () => {
clearTimeout(timeoutRef.current);
setCopyStatus("success");
timeoutRef.current = window.setTimeout(() => {
setCopyStatus(null);
}, TIMEOUT);
};
return {
copyStatus,
onCopy,
};
};
+5
View File
@@ -213,6 +213,7 @@ export {
hashString, hashString,
isInvisiblySmallElement, isInvisiblySmallElement,
getNonDeletedElements, getNonDeletedElements,
getTextFromElements,
} from "./element"; } from "./element";
export { defaultLang, useI18n, languages } from "./i18n"; export { defaultLang, useI18n, languages } from "./i18n";
export { export {
@@ -271,6 +272,7 @@ export { MainMenu };
export { useDevice } from "./components/App"; export { useDevice } from "./components/App";
export { WelcomeScreen }; export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats";
export { DefaultSidebar } from "./components/DefaultSidebar"; export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialog } from "./components/TTDDialog/TTDDialog";
@@ -286,3 +288,6 @@ export {
isElementInsideBBox, isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox, elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/withinBounds"; } from "../utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
+7 -8
View File
@@ -168,6 +168,7 @@
"exportImage": "Export image...", "exportImage": "Export image...",
"export": "Save to...", "export": "Save to...",
"copyToClipboard": "Copy to clipboard", "copyToClipboard": "Copy to clipboard",
"copyLink": "Copy link",
"save": "Save to current file", "save": "Save to current file",
"saveAs": "Save as", "saveAs": "Save as",
"load": "Open", "load": "Open",
@@ -272,8 +273,7 @@
"laser": "Laser pointer", "laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools", "extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw", "mermaidToExcalidraw": "Mermaid to Excalidraw"
"magicSettings": "AI settings"
}, },
"element": { "element": {
"rectangle": "Rectangle", "rectangle": "Rectangle",
@@ -462,16 +462,15 @@
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
"element": "Element", "shapes": "Shapes",
"elements": "Elements",
"height": "Height", "height": "Height",
"scene": "Scene", "scene": "Scene",
"selected": "Selected", "selected": "Selected",
"storage": "Storage", "storage": "Storage",
"fullTitle": "Stats & Element properties", "fullTitle": "Canvas & Shape properties",
"title": "Stats", "title": "Properties",
"generalStats": "General stats", "generalStats": "General",
"elementProperties": "Element properties", "elementProperties": "Shape properties",
"total": "Total", "total": "Total",
"version": "Version", "version": "Version",
"versionCopy": "Click to copy", "versionCopy": "Click to copy",
+12 -2
View File
@@ -1,4 +1,9 @@
import type { NormalizedZoomValue, Point, Zoom } from "./types"; import type {
NormalizedZoomValue,
NullableGridSize,
Point,
Zoom,
} from "./types";
import { import {
DEFAULT_ADAPTIVE_RADIUS, DEFAULT_ADAPTIVE_RADIUS,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
@@ -275,7 +280,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
export const getGridPoint = ( export const getGridPoint = (
x: number, x: number,
y: number, y: number,
gridSize: number | null, gridSize: NullableGridSize,
): [number, number] => { ): [number, number] => {
if (gridSize) { if (gridSize) {
return [ return [
@@ -703,3 +708,8 @@ export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
export const clamp = (value: number, min: number, max: number) => { export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
}; };
export const round = (value: number, precision: number) => {
const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
};
@@ -680,8 +680,11 @@ const _renderInteractiveScene = ({
} }
} }
if (appState.editingElement && isTextElement(appState.editingElement)) { if (
const textElement = allElementsMap.get(appState.editingElement.id) as appState.editingTextElement &&
isTextElement(appState.editingTextElement)
) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
if (textElement && !textElement.autoResize) { if (textElement && !textElement.autoResize) {
@@ -894,7 +897,7 @@ const _renderInteractiveScene = ({
!appState.viewModeEnabled && !appState.viewModeEnabled &&
showBoundingBox && showBoundingBox &&
// do not show transform handles when text is being edited // do not show transform handles when text is being edited
!isTextElement(appState.editingElement) !isTextElement(appState.editingTextElement)
) { ) {
renderTransformHandles( renderTransformHandles(
context, context,
+17 -1
View File
@@ -199,7 +199,7 @@ const generateElementCanvas = (
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas => { ): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
const padding = getCanvasPadding(element); const padding = getCanvasPadding(element);
@@ -210,6 +210,10 @@ const generateElementCanvas = (
zoom, zoom,
); );
if (!width || !height) {
return null;
}
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
@@ -540,6 +544,10 @@ const generateElementWithCanvas = (
appState, appState,
); );
if (!elementWithCanvas) {
return null;
}
elementWithCanvasCache.set(element, elementWithCanvas); elementWithCanvasCache.set(element, elementWithCanvas);
return elementWithCanvas; return elementWithCanvas;
@@ -742,6 +750,10 @@ export const renderElement = (
renderConfig, renderConfig,
appState, appState,
); );
if (!elementWithCanvas) {
return;
}
drawElementFromCanvas( drawElementFromCanvas(
elementWithCanvas, elementWithCanvas,
context, context,
@@ -881,6 +893,10 @@ export const renderElement = (
appState, appState,
); );
if (!elementWithCanvas) {
return;
}
const currentImageSmoothingStatus = context.imageSmoothingEnabled; const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if ( if (
@@ -0,0 +1,66 @@
import type { NewElementSceneRenderConfig } from "../scene/types";
import { throttleRAF } from "../utils";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { renderElement } from "./renderElement";
const _renderNewElementScene = ({
canvas,
rc,
newElement,
elementsMap,
allElementsMap,
scale,
appState,
renderConfig,
}: NewElementSceneRenderConfig) => {
if (canvas) {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
});
// Apply zoom
context.save();
context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") {
renderElement(
newElement,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
}
};
export const renderNewElementSceneThrottled = throttleRAF(
(config: NewElementSceneRenderConfig) => {
_renderNewElementScene(config);
},
{ trailing: true },
);
export const renderNewElementScene = (
renderConfig: NewElementSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
renderNewElementSceneThrottled(renderConfig);
return;
}
_renderNewElementScene(renderConfig);
};
+44 -19
View File
@@ -31,53 +31,77 @@ import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils"; import { throttleRAF } from "../utils";
import { getBoundTextElement } from "../element/textElement"; import { getBoundTextElement } from "../element/textElement";
const GridLineColor = {
Bold: "#dddddd",
Regular: "#e5e5e5",
} as const;
const strokeGrid = ( const strokeGrid = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
/** grid cell pixel size */
gridSize: number, gridSize: number,
/** setting to 1 will disble bold lines */
gridStep: number,
scrollX: number, scrollX: number,
scrollY: number, scrollY: number,
zoom: Zoom, zoom: Zoom,
width: number, width: number,
height: number, height: number,
) => { ) => {
const BOLD_LINE_FREQUENCY = 5; const offsetX = (scrollX % gridSize) - gridSize;
const offsetY = (scrollY % gridSize) - gridSize;
enum GridLineColor { const actualGridSize = gridSize * zoom.value;
Bold = "#cccccc",
Regular = "#e5e5e5",
}
const offsetX =
-Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize);
const offsetY =
-Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize);
const lineWidth = Math.min(1 / zoom.value, 1);
const spaceWidth = 1 / zoom.value; const spaceWidth = 1 / zoom.value;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.save(); context.save();
context.lineWidth = lineWidth;
// Offset rendering by 0.5 to ensure that 1px wide lines are crisp.
// We only do this when zoomed to 100% because otherwise the offset is
// fractional, and also visibly offsets the elements.
// We also do this per-axis, as each axis may already be offset by 0.5.
if (zoom.value === 1) {
context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5);
}
// vertical lines
for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
const isBold = const isBold =
Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0;
// don't render regular lines when zoomed out and they're barely visible
if (!isBold && actualGridSize < 10) {
continue;
}
const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.beginPath(); context.beginPath();
context.setLineDash(isBold ? [] : lineDash); context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(x, offsetY - gridSize); context.moveTo(x, offsetY - gridSize);
context.lineTo(x, offsetY + height + gridSize * 2); context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
context.stroke(); context.stroke();
} }
for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
const isBold = const isBold =
Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0;
if (!isBold && actualGridSize < 10) {
continue;
}
const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.beginPath(); context.beginPath();
context.setLineDash(isBold ? [] : lineDash); context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(offsetX - gridSize, y); context.moveTo(offsetX - gridSize, y);
context.lineTo(offsetX + width + gridSize * 2, y); context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
context.stroke(); context.stroke();
} }
context.restore(); context.restore();
@@ -199,10 +223,11 @@ const _renderStaticScene = ({
context.scale(appState.zoom.value, appState.zoom.value); context.scale(appState.zoom.value, appState.zoom.value);
// Grid // Grid
if (renderGrid && appState.gridSize) { if (renderGrid) {
strokeGrid( strokeGrid(
context, context,
appState.gridSize, appState.gridSize,
appState.gridStep,
appState.scrollX, appState.scrollX,
appState.scrollY, appState.scrollY,
appState.zoom, appState.zoom,
+20 -8
View File
@@ -1,6 +1,7 @@
import { isElementInViewport } from "../element/sizeHelpers"; import { isElementInViewport } from "../element/sizeHelpers";
import { isImageElement } from "../element/typeChecks"; import { isImageElement } from "../element/typeChecks";
import type { import type {
ExcalidrawElement,
NonDeletedElementsMap, NonDeletedElementsMap,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
@@ -64,11 +65,13 @@ export class Renderer {
const getRenderableElements = ({ const getRenderableElements = ({
elements, elements,
editingElement, editingTextElement,
newElementId,
pendingImageElementId, pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingElement: AppState["editingElement"]; editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
@@ -83,12 +86,16 @@ export class Renderer {
} }
} }
if (newElementId === element.id) {
continue;
}
// we don't want to render text element that's being currently edited // we don't want to render text element that's being currently edited
// (it's rendered on remote only) // (it's rendered on remote only)
if ( if (
!editingElement || !editingTextElement ||
editingElement.type !== "text" || editingTextElement.type !== "text" ||
element.id !== editingElement.id element.id !== editingTextElement.id
) { ) {
elementsMap.set(element.id, element); elementsMap.set(element.id, element);
} }
@@ -105,7 +112,8 @@ export class Renderer {
scrollY, scrollY,
height, height,
width, width,
editingElement, editingTextElement,
newElementId,
pendingImageElementId, pendingImageElementId,
// cache-invalidation nonce // cache-invalidation nonce
sceneNonce: _sceneNonce, sceneNonce: _sceneNonce,
@@ -117,7 +125,10 @@ export class Renderer {
scrollY: AppState["scrollY"]; scrollY: AppState["scrollY"];
height: AppState["height"]; height: AppState["height"];
width: AppState["width"]; width: AppState["width"];
editingElement: AppState["editingElement"]; editingTextElement: AppState["editingTextElement"];
/** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>; sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => { }) => {
@@ -125,7 +136,8 @@ export class Renderer {
const elementsMap = getRenderableElements({ const elementsMap = getRenderableElements({
elements, elements,
editingElement, editingTextElement,
newElementId,
pendingImageElementId, pendingImageElementId,
}); });
+5 -1
View File
@@ -15,4 +15,8 @@ export {
getElementAtPosition, getElementAtPosition,
getElementsAtPosition, getElementsAtPosition,
} from "./comparisons"; } from "./comparisons";
export { getNormalizedZoom } from "./zoom"; export {
getNormalizedZoom,
getNormalizedGridSize,
getNormalizedGridStep,
} from "./normalize";
+15
View File
@@ -0,0 +1,15 @@
import { MAX_ZOOM, MIN_ZOOM } from "../constants";
import { clamp, round } from "../math";
import type { NormalizedZoomValue } from "../types";
export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
return clamp(round(zoom, 6), MIN_ZOOM, MAX_ZOOM) as NormalizedZoomValue;
};
export const getNormalizedGridSize = (gridStep: number) => {
return clamp(Math.round(gridStep), 1, 100);
};
export const getNormalizedGridStep = (gridStep: number) => {
return clamp(Math.round(gridStep), 1, 100);
};
+8 -3
View File
@@ -218,10 +218,15 @@ export const getSelectedElements = (
export const getTargetElements = ( export const getTargetElements = (
elements: ElementsMapOrArray, elements: ElementsMapOrArray,
appState: Pick<AppState, "selectedElementIds" | "editingElement">, appState: Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "newElement"
>,
) => ) =>
appState.editingElement appState.editingTextElement
? [appState.editingElement] ? [appState.editingTextElement]
: appState.newElement
? [appState.newElement]
: getSelectedElements(elements, appState, { : getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
+11
View File
@@ -92,6 +92,17 @@ export type InteractiveSceneRenderConfig = {
callback: (data: RenderInteractiveSceneCallback) => void; callback: (data: RenderInteractiveSceneCallback) => void;
}; };
export type NewElementSceneRenderConfig = {
canvas: HTMLCanvasElement | null;
rc: RoughCanvas;
newElement: ExcalidrawElement | null;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
appState: AppState;
renderConfig: StaticCanvasRenderConfig;
};
export type SceneScroll = { export type SceneScroll = {
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
-5
View File
@@ -1,10 +1,5 @@
import { MIN_ZOOM } from "../constants";
import type { AppState, NormalizedZoomValue } from "../types"; import type { AppState, NormalizedZoomValue } from "../types";
export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
return Math.max(MIN_ZOOM, Math.min(zoom, 30)) as NormalizedZoomValue;
};
export const getStateForZoom = ( export const getStateForZoom = (
{ {
viewportX, viewportX,
+40 -31
View File
@@ -19,7 +19,12 @@ import {
getSelectedElements, getSelectedElements,
getVisibleAndNonSelectedElements, getVisibleAndNonSelectedElements,
} from "./scene/selection"; } from "./scene/selection";
import type { AppState, KeyboardModifiersObject, Point } from "./types"; import type {
AppClassProperties,
AppState,
KeyboardModifiersObject,
Point,
} from "./types";
const SNAP_DISTANCE = 8; const SNAP_DISTANCE = 8;
@@ -139,21 +144,24 @@ export class SnapCache {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const isGridModeEnabled = (app: AppClassProperties): boolean =>
app.props.gridModeEnabled ?? app.state.gridModeEnabled;
export const isSnappingEnabled = ({ export const isSnappingEnabled = ({
event, event,
appState, app,
selectedElements, selectedElements,
}: { }: {
appState: AppState; app: AppClassProperties;
event: KeyboardModifiersObject; event: KeyboardModifiersObject;
selectedElements: NonDeletedExcalidrawElement[]; selectedElements: NonDeletedExcalidrawElement[];
}) => { }) => {
if (event) { if (event) {
return ( return (
(appState.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) || (app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!appState.objectsSnapModeEnabled && (!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
appState.gridSize === null) !isGridModeEnabled(app))
); );
} }
@@ -161,7 +169,7 @@ export const isSnappingEnabled = ({
if (selectedElements.length === 1 && selectedElements[0].type === "arrow") { if (selectedElements.length === 1 && selectedElements[0].type === "arrow") {
return false; return false;
} }
return appState.objectsSnapModeEnabled; return app.state.objectsSnapModeEnabled;
}; };
export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
@@ -406,13 +414,13 @@ export const getVisibleGaps = (
const getGapSnaps = ( const getGapSnaps = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
dragOffset: Vector2D, dragOffset: Vector2D,
appState: AppState, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
nearestSnapsX: Snaps, nearestSnapsX: Snaps,
nearestSnapsY: Snaps, nearestSnapsY: Snaps,
minOffset: Vector2D, minOffset: Vector2D,
) => { ) => {
if (!isSnappingEnabled({ appState, event, selectedElements })) { if (!isSnappingEnabled({ app, event, selectedElements })) {
return []; return [];
} }
@@ -596,14 +604,14 @@ export const getReferenceSnapPoints = (
const getPointSnaps = ( const getPointSnaps = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
selectionSnapPoints: Point[], selectionSnapPoints: Point[],
appState: AppState, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
nearestSnapsX: Snaps, nearestSnapsX: Snaps,
nearestSnapsY: Snaps, nearestSnapsY: Snaps,
minOffset: Vector2D, minOffset: Vector2D,
) => { ) => {
if ( if (
!isSnappingEnabled({ appState, event, selectedElements }) || !isSnappingEnabled({ app, event, selectedElements }) ||
(selectedElements.length === 0 && selectionSnapPoints.length === 0) (selectedElements.length === 0 && selectionSnapPoints.length === 0)
) { ) {
return []; return [];
@@ -652,13 +660,14 @@ const getPointSnaps = (
export const snapDraggedElements = ( export const snapDraggedElements = (
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
dragOffset: Vector2D, dragOffset: Vector2D,
appState: AppState, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
const appState = app.state;
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if ( if (
!isSnappingEnabled({ appState, event, selectedElements }) || !isSnappingEnabled({ app, event, selectedElements }) ||
selectedElements.length === 0 selectedElements.length === 0
) { ) {
return { return {
@@ -687,7 +696,7 @@ export const snapDraggedElements = (
getPointSnaps( getPointSnaps(
selectedElements, selectedElements,
selectionPoints, selectionPoints,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -697,7 +706,7 @@ export const snapDraggedElements = (
getGapSnaps( getGapSnaps(
selectedElements, selectedElements,
dragOffset, dragOffset,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -732,7 +741,7 @@ export const snapDraggedElements = (
getElementsCorners(selectedElements, elementsMap, { getElementsCorners(selectedElements, elementsMap, {
dragOffset: newDragOffset, dragOffset: newDragOffset,
}), }),
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -742,7 +751,7 @@ export const snapDraggedElements = (
getGapSnaps( getGapSnaps(
selectedElements, selectedElements,
newDragOffset, newDragOffset,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -1075,13 +1084,13 @@ export const snapResizingElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
// while using the original elements to appy dragOffset to calculate snaps // while using the original elements to appy dragOffset to calculate snaps
selectedOriginalElements: ExcalidrawElement[], selectedOriginalElements: ExcalidrawElement[],
appState: AppState, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
dragOffset: Vector2D, dragOffset: Vector2D,
transformHandle: MaybeTransformHandleType, transformHandle: MaybeTransformHandleType,
) => { ) => {
if ( if (
!isSnappingEnabled({ event, selectedElements, appState }) || !isSnappingEnabled({ event, selectedElements, app }) ||
selectedElements.length === 0 || selectedElements.length === 0 ||
(selectedElements.length === 1 && (selectedElements.length === 1 &&
!areRoughlyEqual(selectedElements[0].angle, 0)) !areRoughlyEqual(selectedElements[0].angle, 0))
@@ -1147,7 +1156,7 @@ export const snapResizingElements = (
} }
} }
const snapDistance = getSnapDistance(appState.zoom.value); const snapDistance = getSnapDistance(app.state.zoom.value);
const minOffset = { const minOffset = {
x: snapDistance, x: snapDistance,
@@ -1160,7 +1169,7 @@ export const snapResizingElements = (
getPointSnaps( getPointSnaps(
selectedOriginalElements, selectedOriginalElements,
selectionSnapPoints, selectionSnapPoints,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -1193,7 +1202,7 @@ export const snapResizingElements = (
getPointSnaps( getPointSnaps(
selectedElements, selectedElements,
corners, corners,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -1210,13 +1219,13 @@ export const snapResizingElements = (
export const snapNewElement = ( export const snapNewElement = (
newElement: ExcalidrawElement, newElement: ExcalidrawElement,
appState: AppState, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
origin: Vector2D, origin: Vector2D,
dragOffset: Vector2D, dragOffset: Vector2D,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
if (!isSnappingEnabled({ event, selectedElements: [newElement], appState })) { if (!isSnappingEnabled({ event, selectedElements: [newElement], app })) {
return { return {
snapOffset: { x: 0, y: 0 }, snapOffset: { x: 0, y: 0 },
snapLines: [], snapLines: [],
@@ -1227,7 +1236,7 @@ export const snapNewElement = (
[origin.x + dragOffset.x, origin.y + dragOffset.y], [origin.x + dragOffset.x, origin.y + dragOffset.y],
]; ];
const snapDistance = getSnapDistance(appState.zoom.value); const snapDistance = getSnapDistance(app.state.zoom.value);
const minOffset = { const minOffset = {
x: snapDistance, x: snapDistance,
@@ -1240,7 +1249,7 @@ export const snapNewElement = (
getPointSnaps( getPointSnaps(
[newElement], [newElement],
selectionSnapPoints, selectionSnapPoints,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -1265,7 +1274,7 @@ export const snapNewElement = (
getPointSnaps( getPointSnaps(
[newElement], [newElement],
corners, corners,
appState, app,
event, event,
nearestSnapsX, nearestSnapsX,
nearestSnapsY, nearestSnapsY,
@@ -1282,12 +1291,12 @@ export const snapNewElement = (
export const getSnapLinesAtPointer = ( export const getSnapLinesAtPointer = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, app: AppClassProperties,
pointer: Vector2D, pointer: Vector2D,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
if (!isSnappingEnabled({ event, selectedElements: [], appState })) { if (!isSnappingEnabled({ event, selectedElements: [], app })) {
return { return {
originOffset: { x: 0, y: 0 }, originOffset: { x: 0, y: 0 },
snapLines: [], snapLines: [],
@@ -1297,11 +1306,11 @@ export const getSnapLinesAtPointer = (
const referenceElements = getVisibleAndNonSelectedElements( const referenceElements = getVisibleAndNonSelectedElements(
elements, elements,
[], [],
appState, app.state,
elementsMap, elementsMap,
); );
const snapDistance = getSnapDistance(appState.zoom.value); const snapDistance = getSnapDistance(app.state.zoom.value);
const minOffset = { const minOffset = {
x: snapDistance, x: snapDistance,
@@ -812,10 +812,10 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -831,7 +831,9 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -1015,10 +1017,10 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -1034,7 +1036,9 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -1197,7 +1201,7 @@ History {
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of elements 1`] = `1`; exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of elements 1`] = `1`;
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `6`; exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `5`;
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
{ {
@@ -1228,10 +1232,10 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -1247,7 +1251,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -1525,7 +1531,7 @@ History {
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `12`; exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `10`;
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = `
{ {
@@ -1556,10 +1562,10 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -1575,7 +1581,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -1853,7 +1861,7 @@ History {
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `12`; exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `10`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
{ {
@@ -1884,10 +1892,10 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -1903,7 +1911,9 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -2066,7 +2076,7 @@ History {
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of elements 1`] = `1`; exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of elements 1`] = `1`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `6`; exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `5`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
{ {
@@ -2097,10 +2107,10 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -2116,7 +2126,9 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -2303,7 +2315,7 @@ History {
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of elements 1`] = `1`; exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of elements 1`] = `1`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `7`; exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
{ {
@@ -2334,10 +2346,10 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -2353,7 +2365,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -2601,7 +2615,7 @@ History {
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `7`; exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
{ {
@@ -2632,10 +2646,10 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -2651,7 +2665,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -2967,7 +2983,7 @@ History {
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `12`; exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `10`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
{ {
@@ -2998,10 +3014,10 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -3017,7 +3033,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -3439,7 +3457,7 @@ History {
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `18`; exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `16`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
{ {
@@ -3470,10 +3488,10 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -3489,7 +3507,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -3759,7 +3779,7 @@ History {
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `11`; exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `9`;
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = `
{ {
@@ -3790,10 +3810,10 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -3809,7 +3829,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -4079,7 +4101,7 @@ History {
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `11`; exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `9`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
{ {
@@ -4110,10 +4132,10 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -4129,7 +4151,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -4479,7 +4503,7 @@ History {
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `13`; exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `11`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = ` exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
{ {
@@ -5293,10 +5317,10 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -5312,7 +5336,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -5603,7 +5629,7 @@ History {
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `12`; exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `10`;
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = ` exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
{ {
@@ -6417,10 +6443,10 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -6436,7 +6462,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -6773,7 +6801,7 @@ History {
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of elements 1`] = `2`; exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `13`; exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `11`;
exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = ` exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = `
{ {
@@ -7349,10 +7377,10 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -7368,7 +7396,9 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -8258,10 +8288,10 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -8277,7 +8307,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -9149,10 +9181,10 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
"editingLinearElement": null, "editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null, "elementsToHighlight": null,
"errorMessage": null, "errorMessage": null,
"exportBackground": true, "exportBackground": true,
@@ -9168,7 +9200,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"outline": true, "outline": true,
}, },
"frameToHighlight": null, "frameToHighlight": null,
"gridSize": null, "gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLoading": false, "isLoading": false,
@@ -9415,6 +9449,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] nu
exports[`contextMenu element > shows context menu for element > [end of test] number of elements 2`] = `2`; exports[`contextMenu element > shows context menu for element > [end of test] number of elements 2`] = `2`;
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `6`; exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `5`;
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`; exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`;
@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 1`] = `1`; exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 3`] = `1`;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 2`] = ` exports[`Test dragCreate > add element to the scene when pointer dragging long enough > arrow 4`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -52,9 +52,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
} }
`; `;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 1`] = `1`; exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 3`] = `1`;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 2`] = ` exports[`Test dragCreate > add element to the scene when pointer dragging long enough > diamond 4`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -88,9 +88,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
} }
`; `;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 1`] = `1`; exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 3`] = `1`;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 2`] = ` exports[`Test dragCreate > add element to the scene when pointer dragging long enough > ellipse 4`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -124,7 +124,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
} }
`; `;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > line 1`] = ` exports[`Test dragCreate > add element to the scene when pointer dragging long enough > line 3`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -173,9 +173,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
} }
`; `;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 1`] = `1`; exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 3`] = `1`;
exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 2`] = ` exports[`Test dragCreate > add element to the scene when pointer dragging long enough > rectangle 4`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
File diff suppressed because it is too large Load Diff
@@ -134,7 +134,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 760410951, "versionNonce": 1723083209,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 23633383, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@@ -106,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 23633383, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
File diff suppressed because it is too large Load Diff
+40 -20
View File
@@ -51,8 +51,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -83,8 +85,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
@@ -116,8 +120,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -148,8 +154,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -184,8 +192,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@@ -225,8 +235,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(5); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -245,8 +257,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(5); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -265,8 +279,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(5); `5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -292,8 +308,10 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -319,8 +337,10 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
expect(renderStaticScene).toHaveBeenCalledTimes(6); `6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -232,8 +232,8 @@ describe("element locking", () => {
API.setElements([container, text]); API.setElements([container, text]);
API.setSelectedElements([container]); API.setSelectedElements([container]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.state.editingElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore locked text under cursor when clicked with text tool", () => { it("should ignore locked text under cursor when clicked with text tool", () => {
@@ -253,9 +253,9 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
expect(h.state.editingElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore text under cursor when double-clicked with selection tool", () => { it("should ignore text under cursor when double-clicked with selection tool", () => {
@@ -275,9 +275,9 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
expect(h.state.editingElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("locking should include bound text", () => { it("locking should include bound text", () => {
@@ -348,9 +348,9 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.state.editingElement?.id).toBe(h.elements[2].id); expect(h.state.editingTextElement?.id).toBe(h.elements[2].id);
}); });
it("bound text shouldn't be editable via text tool", () => { it("bound text shouldn't be editable via text tool", () => {
@@ -382,8 +382,8 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.state.editingElement?.id).toBe(h.elements[2].id); expect(h.state.editingTextElement?.id).toBe(h.elements[2].id);
}); });
}); });
@@ -2,7 +2,7 @@ import React from "react";
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils"; import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
import { Excalidraw, Footer, MainMenu } from "../index"; import { Excalidraw, Footer, MainMenu } from "../index";
import { queryByText, queryByTestId } from "@testing-library/react"; import { queryByText, queryByTestId } from "@testing-library/react";
import { GRID_SIZE, THEME } from "../constants"; import { THEME } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -91,7 +91,7 @@ describe("<Excalidraw/>", () => {
describe("Test gridModeEnabled prop", () => { describe("Test gridModeEnabled prop", () => {
it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => { it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => {
const { container } = await render(<Excalidraw />); const { container } = await render(<Excalidraw />);
expect(h.state.gridSize).toBe(null); expect(h.state.gridModeEnabled).toBe(false);
expect( expect(
container.getElementsByClassName("disable-zen-mode--visible").length, container.getElementsByClassName("disable-zen-mode--visible").length,
@@ -103,14 +103,14 @@ describe("<Excalidraw/>", () => {
}); });
const contextMenu = document.querySelector(".context-menu"); const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Toggle grid")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Toggle grid")!);
expect(h.state.gridSize).toBe(GRID_SIZE); expect(h.state.gridModeEnabled).toBe(true);
}); });
it('should not show grid mode in context menu when gridModeEnabled is not "undefined"', async () => { it('should not show grid mode in context menu when gridModeEnabled is not "undefined"', async () => {
const { container } = await render( const { container } = await render(
<Excalidraw gridModeEnabled={false} />, <Excalidraw gridModeEnabled={false} />,
); );
expect(h.state.gridSize).toBe(null); expect(h.state.gridModeEnabled).toBe(false);
expect( expect(
container.getElementsByClassName("disable-zen-mode--visible").length, container.getElementsByClassName("disable-zen-mode--visible").length,
@@ -122,7 +122,7 @@ describe("<Excalidraw/>", () => {
}); });
const contextMenu = document.querySelector(".context-menu"); const contextMenu = document.querySelector(".context-menu");
expect(queryByText(contextMenu as HTMLElement, "Show grid")).toBe(null); expect(queryByText(contextMenu as HTMLElement, "Show grid")).toBe(null);
expect(h.state.gridSize).toBe(null); expect(h.state.gridModeEnabled).toBe(false);
}); });
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ export const diagramFixture = {
elements: [diamondFixture, ellipseFixture, rectangleFixture], elements: [diamondFixture, ellipseFixture, rectangleFixture],
appState: { appState: {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
gridSize: null, gridModeEnabled: false,
}, },
files: {}, files: {},
}; };
@@ -0,0 +1,2 @@
export const yellow = (str: string) => `\u001b[33m${str}\u001b[0m`;
export const red = (str: string) => `\u001b[31m${str}\u001b[0m`;
+17 -1
View File
@@ -579,7 +579,23 @@ export class UI {
static queryStats = () => { static queryStats = () => {
return GlobalTestState.renderResult.container.querySelector( return GlobalTestState.renderResult.container.querySelector(
".Stats", ".exc-stats",
) as HTMLElement | null; ) as HTMLElement | null;
}; };
static queryStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
expect(elementStats).not.toBeNull();
if (elementStats) {
return (
elementStats?.querySelector(
`.exc-stats__row .drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
} }
+5 -5
View File
@@ -753,7 +753,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
isDeleted: false, isDeleted: false,
@@ -961,7 +961,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
isDeleted: false, isDeleted: false,
@@ -2727,7 +2727,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
@@ -2740,8 +2740,8 @@ describe("history", () => {
}); });
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(3);
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
+6 -6
View File
@@ -48,9 +48,9 @@ describe("move element", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`, `5`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -96,9 +96,9 @@ describe("move element", () => {
new Pointer("mouse").clickOn(rectB); new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`19`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`16`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3); expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@@ -146,9 +146,9 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`, `5`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -115,8 +115,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
@@ -158,8 +158,8 @@ describe("multi point mode in linear elements", () => {
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
+2 -2
View File
@@ -77,6 +77,6 @@ test("unselected bound arrows update when rotating their target elements", async
expect(textArrow.x).toEqual(360); expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300); expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]); expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-94, 1); expect(textArrow.points[1][0]).toBeCloseTo(-94, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 1); expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0);
}); });
+10 -10
View File
@@ -314,8 +314,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -346,8 +346,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -378,8 +378,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -423,8 +423,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -467,8 +467,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
+23 -17
View File
@@ -40,7 +40,7 @@ import type { FileSystemHandle } from "./data/filesystem";
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf } from "./utility-types"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType } from "./store"; import type { StoreActionType } from "./store";
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@@ -176,6 +176,7 @@ export type StaticCanvasAppState = Readonly<
exportScale: AppState["exportScale"]; exportScale: AppState["exportScale"];
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"]; selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
gridSize: AppState["gridSize"]; gridSize: AppState["gridSize"];
gridStep: AppState["gridStep"];
frameRendering: AppState["frameRendering"]; frameRendering: AppState["frameRendering"];
currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"];
} }
@@ -199,7 +200,7 @@ export type InteractiveCanvasAppState = Readonly<
// SnapLines // SnapLines
snapLines: AppState["snapLines"]; snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"]; zenModeEnabled: AppState["zenModeEnabled"];
editingElement: AppState["editingElement"]; editingTextElement: AppState["editingTextElement"];
} }
>; >;
@@ -267,13 +268,9 @@ export interface AppState {
editingFrame: string | null; editingFrame: string | null;
elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null; elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null;
/** /**
* currently set for: * set when a new text is created or when an existing text is being edited
* - text elements while in wysiwyg
* - newly created linear elements while in line editor (not set for existing
* elements in line editor)
* - and new images while being placed on canvas
*/ */
editingElement: NonDeletedExcalidrawElement | null; editingTextElement: NonDeletedExcalidrawElement | null;
editingLinearElement: LinearElementEditor | null; editingLinearElement: LinearElementEditor | null;
activeTool: { activeTool: {
/** /**
@@ -324,14 +321,6 @@ export interface AppState {
openDialog: openDialog:
| null | null
| { name: "imageExport" | "help" | "jsonExport" } | { name: "imageExport" | "help" | "jsonExport" }
| {
name: "settings";
source:
| "tool" // when magicframe tool is selected
| "generation" // when magicframe generate button is clicked
| "settings"; // when AI settings dialog is explicitly invoked
tab: "text-to-diagram" | "diagram-to-code";
}
| { name: "ttd"; tab: "text-to-diagram" | "mermaid" } | { name: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { name: "commandPalette" }; | { name: "commandPalette" };
/** /**
@@ -351,7 +340,10 @@ export interface AppState {
toast: { message: string; closable?: boolean; duration?: number } | null; toast: { message: string; closable?: boolean; duration?: number } | null;
zenModeEnabled: boolean; zenModeEnabled: boolean;
theme: Theme; theme: Theme;
gridSize: number | null; /** grid cell px size */
gridSize: number;
gridStep: number;
gridModeEnabled: boolean;
viewModeEnabled: boolean; viewModeEnabled: boolean;
/** top-most selected groups (i.e. does not include nested groups) */ /** top-most selected groups (i.e. does not include nested groups) */
@@ -615,6 +607,7 @@ export type AppProps = Merge<
* in the app, eg Manager. Factored out into a separate type to keep DRY. */ * in the app, eg Manager. Factored out into a separate type to keep DRY. */
export type AppClassProperties = { export type AppClassProperties = {
props: AppProps; props: AppProps;
state: AppState;
interactiveCanvas: HTMLCanvasElement | null; interactiveCanvas: HTMLCanvasElement | null;
/** static canvas */ /** static canvas */
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@@ -649,6 +642,9 @@ export type AppClassProperties = {
getName: App["getName"]; getName: App["getName"];
dismissLinearEditor: App["dismissLinearEditor"]; dismissLinearEditor: App["dismissLinearEditor"];
flowChartCreator: App["flowChartCreator"]; flowChartCreator: App["flowChartCreator"];
getEffectiveGridSize: App["getEffectiveGridSize"];
setPlugins: App["setPlugins"];
plugins: App["plugins"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@@ -831,3 +827,13 @@ export type EmbedsValidationStatus = Map<
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>; export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
export type PendingExcalidrawElements = ExcalidrawElement[]; export type PendingExcalidrawElements = ExcalidrawElement[];
/** Runtime gridSize value. Null indicates disabled grid. */
export type NullableGridSize =
| (AppState["gridSize"] & MakeBrand<"NullableGridSize">)
| null;
export type GenerateDiagramToCode = (props: {
frame: ExcalidrawMagicFrameElement;
children: readonly ExcalidrawElement[];
}) => MaybePromise<{ html: string }>;

Some files were not shown because too many files have changed in this diff Show More