Compare commits

...

23 Commits

Author SHA1 Message Date
spc-28 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
zsviczian afb68a6467 feat: improve elbow arrow keyboard move (#8392) 2024-08-26 15:58:54 +02:00
zsviczian 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 Luzar 5facc0d6da fix: perf regression in getCommonBounds (#8429) 2024-08-26 15:57:28 +02:00
Paul Tune 824ad603e1 docs: Update function call in @excalidraw/mermaid-to-excalidraw (#8420) 2024-08-26 13:20:29 +02:00
Ryan Di 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 Luzar b5d7f5b4ba feat: rewrite d2c to not require token (#8269) 2024-08-20 18:06:22 +02:00
David Luzar fb4bb29aa5 fix: object snapping not working (#8381) 2024-08-15 18:48:25 +02:00
David Luzar 3cfcc7b489 feat: split gridSize from enabled state & support custom gridStep (#8364) 2024-08-14 14:59:14 +02:00
David Luzar 4320a3cf41 feat: improve zoom-to-content when creating flowchart (#8368) 2024-08-12 20:42:08 +02:00
Márk Tolmács 8420e1aa13 fix: Reimplement rectangle intersection (#8367) 2024-08-12 19:19:16 +02:00
Márk Tolmács 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 Luzar 97981804d7 feat: Stats popup style tweaks (#8361) 2024-08-11 19:33:44 +02:00
Clarence Chan 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
DDDDD12138 7b2bee9746 chore: remove unused parameter (#8355) 2024-08-09 21:39:40 +02:00
David Luzar 88014ace4a fix: drawing from 0-dimension canvas (#8356) 2024-08-09 21:36:04 +02:00
David Luzar 87a9430809 fix: disable flowchart keybindings inside inputs (#8353) 2024-08-09 18:44:17 +02:00
Márk Tolmács 99b91c46f7 fix: Yet more patching of intersect code (#8352)
* Yet more patching of intersect code
2024-08-09 17:33:12 +02:00
David Luzar 1ea5b26f25 fix: missing act() in flowchart tests (#8354) 2024-08-09 17:27:02 +02:00
Clarence Chan d5f4ee7b3f fix: z-index change by one causes app to freeze (#8314) 2024-08-09 15:26:08 +02:00
Márk Tolmács 261304c1a4 fix: Patch over intersection calculation issue (#8350)
* Patch over intersection calculation issue
2024-08-09 14:40:57 +02:00
Márk Tolmács 84398a7e5c fix: Point duplication in LEE on ALT+click (#8347) 2024-08-09 12:01:32 +02:00
Ryan Di 54491d13d4 feat: create flowcharts from a generic element using elbow arrows (#8329)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-08 21:43:15 +02:00
112 changed files with 4159 additions and 2121 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 { convertToExcalidrawElements} from "@excalidraw/excalidraw"
try {
const { elements, files } = await parseMermaid(mermaidSyntax: string, {
const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
fontSize: number,
});
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, ...)
"appState": {
"gridSize": null,
"gridSize": 20,
"viewBackgroundColor": "#ffffff"
},
+2 -57
View File
@@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
StoreAction,
reconcileElements,
@@ -121,6 +120,7 @@ import {
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import { AIComponents } from "./components/AI";
polyfill();
@@ -846,63 +846,8 @@ const ExcalidrawWrapper = () => {
)}
</OverwriteConfirmDialog>
<AppFooter />
<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 }),
},
);
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
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 />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
+28 -33
View File
@@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number };
@@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
}
return (
<>
<tr>
<th colSpan={2}>{t("stats.storage")}</th>
</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" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</>
<Stats.StatsRows order={-1}>
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<Stats.StatsRow
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</Stats.StatsRow>
<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>
);
};
+20 -14
View File
@@ -116,20 +116,26 @@ class Portal {
}
}
this.collab.excalidrawAPI.updateScene({
elements: this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
let isChanged = false;
const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
storeAction: StoreAction.UPDATE,
});
}
}, FILE_UPLOAD_TIMEOUT);
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;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
background: var(--color-success);
color: var(--color-success-text);
& > svg {
width: 0.875rem;
+13 -23
View File
@@ -1,5 +1,4 @@
import { useEffect, 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";
@@ -14,7 +13,6 @@ import {
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
handleClose: () => void;
}) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const { onCopy, copyStatus } = useCopyStatus();
const copyRoomLink = async () => {
try {
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
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="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
<FilledButton
size="large"
label={t("buttons.copyLink")}
icon={copyIcon}
status={copyStatus}
onClick={() => {
copyRoomLink();
onCopy();
}}
/>
</div>
<div className="ShareDialog__active__description">
<p>
+2
View File
@@ -39,6 +39,8 @@ Please add the latest change on the top under the correct section.
### 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)
| | Before `commitToHistory` | After `storeAction` | Notes |
+24 -18
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import type { AppState, NormalizedZoomValue } from "../types";
import type { AppState } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
import { clamp } from "../math";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -104,6 +105,8 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
pasteDialog: appState.pasteDialog,
activeTool:
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
const zoomValueToFitBoundsOnViewport = (
bounds: SceneBounds,
viewportDimensions: { width: number; height: number },
viewportZoomFactor: number = 1, // default to 1 if not provided
) => {
const [x1, y1, x2, y2] = bounds;
const commonBoundsWidth = x2 - x1;
@@ -251,20 +255,21 @@ const zoomValueToFitBoundsOnViewport = (
const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
};
export const zoomToFitBounds = ({
bounds,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
viewportZoomFactor = 1,
}: {
bounds: SceneBounds;
appState: Readonly<AppState>;
@@ -289,13 +294,9 @@ export const zoomToFitBounds = ({
Math.min(
appState.width / commonBoundsWidth,
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 = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
newZoomValue = getNormalizedZoom(newZoomValue);
let appStateWidth = appState.width;
@@ -314,10 +315,14 @@ export const zoomToFitBounds = ({
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
width: appState.width,
height: appState.height,
});
newZoomValue = zoomValueToFitBoundsOnViewport(
bounds,
{
width: appState.width,
height: appState.height,
},
viewportZoomFactor,
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
@@ -408,6 +413,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null,
},
fitToViewport: true,
viewportZoomFactor: 0.7,
});
},
// NOTE this action should use shift-2 per figma, alas
@@ -10,7 +10,7 @@ import {
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { getTextFromElements, isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -239,16 +239,8 @@ export const copyText = register({
includeBoundTextElement: true,
});
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
try {
copyTextToSystemClipboard(text);
copyTextToSystemClipboard(getTextFromElements(selectedElements));
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
@@ -15,7 +15,7 @@ import {
import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import type { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import { DEFAULT_GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
@@ -99,8 +99,8 @@ const duplicateElements = (
groupIdMap,
element,
{
x: element.x + GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2,
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);
@@ -50,7 +50,6 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
};
@@ -179,7 +178,7 @@ export const actionFinalize = register({
newElement: null,
selectionElement: null,
multiElement: null,
editingElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppState } from "../types";
import type { AppClassProperties, AppState } from "../types";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
@@ -13,17 +13,19 @@ import type { Store } from "../store";
import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter";
const writeData = (
const executeHistoryAction = (
app: AppClassProperties,
appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void,
): ActionResult => {
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.editingTextElement &&
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement
!appState.selectionElement &&
!app.flowChartCreator.isCreatingChart
) {
const result = updater();
@@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, value, app) =>
writeData(appState, () =>
executeHistoryAction(app, appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
@@ -94,7 +96,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
writeData(appState, () =>
executeHistoryAction(app, appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
@@ -133,7 +133,7 @@ export const changeProperty = (
return elements.map((element) => {
if (
selectedElementIds.get(element.id) ||
element.id === appState.editingElement?.id
element.id === appState.editingTextElement?.id
) {
return callback(element);
}
@@ -148,13 +148,13 @@ export const getFormValue = function <T extends Primitive>(
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingElement = appState.editingElement;
const editingTextElement = appState.editingTextElement;
const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null;
if (editingElement) {
ret = getAttribute(editingElement);
if (editingTextElement) {
ret = getAttribute(editingTextElement);
}
if (!ret) {
@@ -1076,19 +1076,20 @@ export const actionChangeFontFamily = register({
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingElement } = appState;
const { editingTextElement } = appState;
if (editingElement?.type === "text") {
// retrieve the latest version from the scene, as `editingElement` isn't mutated
const latestEditingElement = app.scene.getElement(
editingElement.id,
// still check type to be safe
if (editingTextElement?.type === "text") {
// retrieve the latest version from the scene, as `editingTextElement` isn't mutated
const latesteditingTextElement = app.scene.getElement(
editingTextElement.id,
);
// inside the wysiwyg editor
cachedElementsRef.current.set(
editingElement.id,
editingTextElement.id,
newElementWith(
latestEditingElement || editingElement,
latesteditingTextElement || editingTextElement,
{},
true,
),
@@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import type { AppState } from "../types";
import { gridIcon } from "../components/icons";
import { StoreAction } from "../store";
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
gridModeEnabled: !this.checked!(appState),
objectsSnapModeEnabled: false,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
appState: {
...appState,
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
gridModeEnabled: false,
},
storeAction: StoreAction.NONE,
};
+9 -3
View File
@@ -5,9 +5,11 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES,
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
} from "./constants";
import type { AppState, NormalizedZoomValue } from "./types";
@@ -42,7 +44,7 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up",
activeEmbeddable: null,
newElement: null,
editingElement: null,
editingTextElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
@@ -59,7 +61,9 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false,
exportWithDarkMode: false,
fileHandle: null,
gridSize: null,
gridSize: DEFAULT_GRID_SIZE,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true,
defaultSidebarDockedPreference: false,
isLoading: false,
@@ -161,7 +165,7 @@ const APP_STATE_STORAGE_CONF = (<
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { 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 },
editingLinearElement: { browser: false, 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 },
fileHandle: { browser: false, export: false, server: false },
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 },
isBindingEnabled: { browser: false, export: false, server: false },
defaultSidebarDockedPreference: {
+5 -18
View File
@@ -45,7 +45,6 @@ import {
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
@@ -104,7 +103,9 @@ export const SelectedShapeActions = ({
) {
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement);
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@@ -234,7 +235,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
)}
{!isEditing && targetElements.length > 0 && (
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
@@ -400,7 +401,7 @@ export const ShapesSwitcher = ({
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
@@ -410,20 +411,6 @@ export const ShapesSwitcher = ({
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</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>
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-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;
.ExcButton__contents {
@@ -28,6 +36,10 @@
}
}
&[disabled] {
pointer-events: none;
}
&,
&__contents {
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 {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
@@ -5,9 +5,15 @@ import "./FilledButton.scss";
import { AbortError } from "../errors";
import Spinner from "./Spinner";
import { isPromiseLike } from "../utils";
import { tablerCheckIcon } from "./icons";
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 FilledButtonProps = {
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void;
status?: null | "loading" | "success";
variant?: ButtonVariant;
color?: ButtonColor;
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
size = "medium",
fullWidth,
className,
status,
},
ref,
) => {
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
const ret = onClick?.(event);
if (isPromiseLike(ret)) {
try {
// delay loading state to prevent flicker in case of quick response
const timer = window.setTimeout(() => {
setIsLoading(true);
}, 50);
try {
await ret;
} catch (error: any) {
if (!(error instanceof AbortError)) {
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
console.warn(error);
}
} finally {
clearTimeout(timer);
setIsLoading(false);
}
}
};
const _status = isLoading ? "loading" : status;
color = _status === "success" ? "success" : color;
return (
<button
className={clsx(
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
`ExcButton--status-${_status}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button"
aria-label={label}
ref={ref}
disabled={isLoading}
disabled={_status === "loading" || _status === "success"}
>
<div className="ExcButton__contents">
{isLoading && <Spinner />}
{_status === "loading" ? (
<Spinner className="ExcButton__statusIcon" />
) : (
_status === "success" && (
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
)
)}
{icon && (
<div className="ExcButton__icon" aria-hidden>
{icon}
@@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--editor"
caption={t("helpDialog.editor")}
>
<Shortcut
label={t("helpDialog.createFlowchart")}
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.navigateFlowchart")}
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
box-sizing: border-box;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
left: 0;
top: 100%;
+35 -7
View File
@@ -1,6 +1,7 @@
import { t } from "../i18n";
import type { AppClassProperties, Device, UIAppState } from "../types";
import {
isFlowchartNodeElement,
isImageElement,
isLinearElement,
isTextBindableContainer,
@@ -10,6 +11,8 @@ import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping";
interface HintViewerProps {
appState: UIAppState;
@@ -18,7 +21,12 @@ interface HintViewerProps {
app: AppClassProperties;
}
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const getHints = ({
appState,
isMobile,
device,
app,
}: HintViewerProps): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@@ -79,7 +87,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
if (appState.editingTextElement) {
return t("hints.text_editing");
}
@@ -87,13 +95,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if (
appState.selectionElement &&
!selectedElements.length &&
!appState.editingElement &&
!appState.editingTextElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");
}
if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping");
}
@@ -115,6 +123,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
!appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0])
) {
if (isFlowchartNodeElement(selectedElements[0])) {
if (
isNodeInFlowchart(
selectedElements[0],
app.scene.getNonDeletedElementsMap(),
)
) {
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
}
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
}
return t("hints.bindTextToElement");
}
}
@@ -129,17 +150,24 @@ export const HintViewer = ({
device,
app,
}: HintViewerProps) => {
let hint = getHints({
const hints = getHints({
appState,
isMobile,
device,
app,
});
if (!hint) {
if (!hints) {
return null;
}
hint = getShortcutKey(hint);
const hint = Array.isArray(hints)
? hints
.map((hint) => {
return getShortcutKey(hint).replace(/\. ?$/, "");
})
.join(". ")
: getShortcutKey(hints);
return (
<div className="HintViewer">
@@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -89,6 +90,8 @@ const ImageExportModal = ({
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus } = useCopyStatus();
const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot,
appStateSnapshot,
@@ -294,11 +297,17 @@ const ImageExportModal = ({
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
exportingFrame,
})
}
status={copyStatus}
onClick={async () => {
await onExportImage(
EXPORT_IMAGE_TYPES.clipboard,
exportedElements,
{
exportingFrame,
},
);
onCopy();
}}
icon={copyIcon}
>
{t("imageExportDialog.button.copyPngToClipboard")}
@@ -27,99 +27,6 @@
& > * {
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 {
+1 -33
View File
@@ -60,7 +60,6 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
@@ -85,14 +84,6 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
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<{
@@ -149,10 +140,6 @@ const LayerUI = ({
children,
app,
isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@@ -360,7 +347,7 @@ const LayerUI = ({
)}
{shouldShowStats && (
<Stats
scene={app.scene}
app={app}
onClose={() => {
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 />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{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;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
background: var(--color-success);
color: var(--color-success-text);
& > svg {
width: 0.875rem;
@@ -1,5 +1,4 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { copyIcon } from "./icons";
import "./ShareableLinkDialog.scss";
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
ref.current?.select();
};
const { onCopy, copyStatus } = useCopyStatus();
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
value={link}
selectOnRender
/>
<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="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
<FilledButton
size="large"
label={t("buttons.copyLink")}
icon={copyIcon}
status={copyStatus}
onClick={() => {
onCopy();
copyRoomLink();
}}
/>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
+3 -1
View File
@@ -6,16 +6,18 @@ const Spinner = ({
size = "1em",
circleWidth = 8,
synchronized = false,
className = "",
}: {
size?: string | number;
circleWidth?: number;
synchronized?: boolean;
className?: string;
}) => {
const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600);
return (
<div className="Spinner">
<div className={`Spinner ${className}`}>
<svg
viewBox="0 0 100 100"
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}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div>
{open && <>{children}</>}
{open && (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
</div>
)}
</>
);
};
@@ -23,7 +23,6 @@ const handleDimensionChange: DragInputCallbackType<
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
@@ -5,7 +5,7 @@
&:focus-within {
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;
border: 1px solid var(--default-border-color);
border-right: 0;
width: 2rem;
padding: 0 0.5rem 0 0.75rem;
min-width: 1rem;
height: 2rem;
box-sizing: border-box;
color: var(--popup-text-color);
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
}
: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-left: 0;
}
@@ -55,11 +56,11 @@
letter-spacing: 0.4px;
: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"] & {
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-right: 0;
}
@@ -29,6 +29,7 @@ export type DragInputCallbackType<
nextValue?: number;
property: P;
originalAppState: AppState;
setInputValue: (value: number) => void;
}) => void;
interface StatsDragInputProps<
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
property: T;
scene: Scene;
appState: AppState;
/** how many px you need to drag to get 1 unit change */
sensitivity?: number;
}
const StatsDragInput = <
@@ -61,6 +64,7 @@ const StatsDragInput = <
property,
scene,
appState,
sensitivity = 1,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
@@ -126,27 +130,49 @@ const StatsDragInput = <
nextValue: rounded,
property,
originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
};
const handleInputValueRef = useRef(handleInputValue);
handleInputValueRef.current = handleInputValue;
const callbacksRef = useRef<
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)
// updates current input value (blur isn't triggered)
useEffect(() => {
const input = inputRef.current;
const callbacks = callbacksRef.current;
return () => {
const nextValue = input?.value;
if (nextValue) {
handleInputValueRef.current(
callbacks.handleInputValue?.(
nextValue,
stateRef.current.originalElements,
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
@@ -172,6 +198,8 @@ const StatsDragInput = <
ref={labelRef}
onPointerDown={(event) => {
if (inputRef.current && editable) {
document.body.classList.add("excalidraw-cursor-resize");
let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) {
startValue = 0;
@@ -196,35 +224,43 @@ const StatsDragInput = <
const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize");
let accumulatedChange = 0;
let stepChange = 0;
const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if (
lastPointer &&
originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null
originalElements !== null
) {
const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
dragInputCallback({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
});
if (instantChange !== 0) {
stepChange += instantChange;
if (Math.abs(stepChange) >= sensitivity) {
stepChange =
Math.sign(stepChange) *
Math.floor(Math.abs(stepChange) / sensitivity);
accumulatedChange += stepChange;
dragInputCallback({
accumulatedChange,
instantChange: stepChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
});
stepChange = 0;
}
}
}
lastPointer = {
@@ -233,27 +269,31 @@ const StatsDragInput = <
};
};
const onPointerUp = () => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = 0;
stepChange = 0;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
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,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = null;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
},
false,
);
window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
}
}}
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%;
}
}
}
+221 -133
View File
@@ -2,13 +2,16 @@ import { useEffect, useMemo, useState, memo } from "react";
import { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import type { AppState, ExcalidrawProps } from "../../types";
import type {
AppClassProperties,
AppState,
ExcalidrawProps,
} from "../../types";
import { CloseIcon } from "../icons";
import { Island } from "../Island";
import { throttle } from "lodash";
import Dimension from "./Dimension";
import Angle from "./Angle";
import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups";
@@ -17,14 +20,18 @@ import MultiFontSize from "./MultiFontSize";
import Position from "./Position";
import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
interface StatsProps {
scene: Scene;
app: AppClassProperties;
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}
@@ -33,11 +40,12 @@ const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState();
const sceneNonce = props.scene.getSceneNonce() || 1;
const selectedElements = props.scene.getSelectedElements({
const sceneNonce = props.app.scene.getSceneNonce() || 1;
const selectedElements = props.app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
const gridModeEnabled = isGridModeEnabled(props.app);
return (
<StatsInner
@@ -45,23 +53,71 @@ export const Stats = (props: StatsProps) => {
appState={appState}
sceneNonce={sceneNonce}
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(
({
scene,
app,
onClose,
renderCustomStats,
selectedElements,
appState,
sceneNonce,
gridModeEnabled,
}: StatsProps & {
sceneNonce: number;
selectedElements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
gridModeEnabled: boolean;
}) => {
const scene = app.scene;
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
const setAppState = useExcalidrawSetAppState();
@@ -106,7 +162,7 @@ export const StatsInner = memo(
}, [selectedElements, appState]);
return (
<div className="Stats">
<div className="exc-stats">
<Island padding={3}>
<div className="title">
<h2>{t("stats.title")}</h2>
@@ -121,7 +177,6 @@ export const StatsInner = memo(
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels: state.stats.panels ^ STATS_PANELS.generalStats,
@@ -130,26 +185,36 @@ export const StatsInner = memo(
})
}
>
<table>
<tbody>
<tr>
<th colSpan={2}>{t("stats.scene")}</th>
</tr>
<tr>
<td>{t("stats.elements")}</td>
<td>{elements.length}</td>
</tr>
<tr>
<td>{t("stats.width")}</td>
<td>{sceneDimension.width}</td>
</tr>
<tr>
<td>{t("stats.height")}</td>
<td>{sceneDimension.height}</td>
</tr>
{renderCustomStats?.(elements, appState)}
</tbody>
</table>
<StatsRows>
<StatsRow heading>{t("stats.scene")}</StatsRow>
<StatsRow columns={2}>
<div>{t("stats.shapes")}</div>
<div>{elements.length}</div>
</StatsRow>
<StatsRow columns={2}>
<div>{t("stats.width")}</div>
<div>{sceneDimension.width}</div>
</StatsRow>
<StatsRow columns={2}>
<div>{t("stats.height")}</div>
<div>{sceneDimension.height}</div>
</StatsRow>
{gridModeEnabled && (
<>
<StatsRow heading>Canvas</StatsRow>
<StatsRow>
<CanvasGrid
property="gridStep"
scene={scene}
appState={appState}
setAppState={setAppState}
/>
</StatsRow>
</>
)}
</StatsRows>
{renderCustomStats?.(elements, appState)}
</Collapsible>
{selectedElements.length > 0 && (
@@ -167,7 +232,6 @@ export const StatsInner = memo(
openTrigger={() =>
setAppState((state) => {
return {
...state,
stats: {
open: true,
panels:
@@ -177,117 +241,139 @@ export const StatsInner = memo(
})
}
>
{singleElement && (
<div className="sectionContent">
<div className="elementType">
{t(`element.${singleElement.type}`)}
</div>
<StatsRows>
{singleElement && (
<>
<StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)}
</StatsRow>
<div className="statsItem">
<Position
element={singleElement}
property="x"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Position
element={singleElement}
property="y"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Dimension
property="width"
element={singleElement}
scene={scene}
appState={appState}
/>
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
/>
{!isElbowArrow(singleElement) && (
<Angle
property="angle"
<StatsRow>
<Position
element={singleElement}
property="x"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<Position
element={singleElement}
property="y"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<Dimension
property="width"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
{!isElbowArrow(singleElement) && (
<StatsRow>
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
)}
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/>
</div>
</div>
)}
<StatsRow>
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
</>
)}
{multipleElements && (
<div className="sectionContent">
{elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div>
)}
{multipleElements && (
<>
{elementsAreInSameGroup(multipleElements) && (
<StatsRow heading>{t("element.group")}</StatsRow>
)}
<div className="elementsCount">
<div>{t("stats.elements")}</div>
<div>{selectedElements.length}</div>
</div>
<StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
<div>{t("stats.shapes")}</div>
<div>{selectedElements.length}</div>
</StatsRow>
<div className="statsItem">
<MultiPosition
property="x"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiPosition
property="y"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiAngle
property="angle"
elements={multipleElements}
scene={scene}
appState={appState}
/>
<MultiFontSize
property="fontSize"
elements={multipleElements}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
</div>
</div>
)}
<StatsRow>
<MultiPosition
property="x"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiPosition
property="y"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiDimension
property="width"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiDimension
property="height"
elements={multipleElements}
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiAngle
property="angle"
elements={multipleElements}
scene={scene}
appState={appState}
/>
</StatsRow>
<StatsRow>
<MultiFontSize
property="fontSize"
elements={multipleElements}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
</StatsRow>
</>
)}
</StatsRows>
</Collapsible>
</div>
)}
@@ -299,7 +385,9 @@ export const StatsInner = memo(
return (
prev.sceneNonce === next.sceneNonce &&
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 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 = (
element: ExcalidrawElement,
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
@@ -54,7 +39,7 @@ const testInputProperty = (
initialValue: number,
nextValue: number,
) => {
const input = getStatsProperty(label)?.querySelector(
const input = UI.queryStatsProperty(label)?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
@@ -136,7 +121,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -148,7 +133,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -159,7 +144,7 @@ describe("binding with linear elements", () => {
it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -171,7 +156,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -225,18 +210,14 @@ describe("stats for a generic element", () => {
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
// element type
const elementType = elementStats?.querySelector(".elementType");
const elementType = queryByTestId(elementStats!, "stats-element-type");
expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect(
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
),
stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
).toBeDefined();
});
});
@@ -257,7 +238,7 @@ describe("stats for a generic element", () => {
const rectangle = h.elements[0];
const rectangleId = rectangle.id;
const input = getStatsProperty("W")?.querySelector(
const input = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
@@ -287,11 +268,11 @@ describe("stats for a generic element", () => {
rectangle.angle,
);
const xInput = getStatsProperty("X")?.querySelector(
const xInput = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
const yInput = getStatsProperty("Y")?.querySelector(
const yInput = UI.queryStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -417,7 +398,7 @@ describe("stats for a non-generic element", () => {
elementStats = stats?.querySelector("#elementStats");
// can change font size
const input = getStatsProperty("F")?.querySelector(
const input = UI.queryStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
@@ -426,9 +407,9 @@ describe("stats for a non-generic element", () => {
expect(text.fontSize).toBe(36);
// cannot change width or height
const width = getStatsProperty("W")?.querySelector(".drag-input");
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined();
const height = getStatsProperty("H")?.querySelector(".drag-input");
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined();
// min font size is 4
@@ -456,7 +437,7 @@ describe("stats for a non-generic element", () => {
expect(elementStats).toBeDefined();
// cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input");
const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined();
// can change width or height
@@ -506,7 +487,7 @@ describe("stats for a non-generic element", () => {
API.setElements([container, text]);
API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector(
const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
@@ -570,15 +551,15 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width?.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height?.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle.value).toBe("0");
@@ -629,25 +610,25 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector(
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle).toBeDefined();
expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector(
const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
@@ -692,7 +673,7 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats");
const x = getStatsProperty("X")?.querySelector(
const x = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -705,7 +686,7 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].x).toBe(400);
expect(x.value).toBe("300");
const y = getStatsProperty("Y")?.querySelector(
const y = UI.queryStatsProperty("Y")?.querySelector(
".drag-input",
) as HTMLInputElement;
@@ -718,13 +699,13 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].y).toBe(300);
expect(y.value).toBe("200");
const width = getStatsProperty("W")?.querySelector(
const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector(
const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
@@ -41,7 +41,8 @@ export type StatsInputProperty =
| "width"
| "height"
| "angle"
| "fontSize";
| "fontSize"
| "gridStep";
export const SMALLEST_DELTA = 0.01;
@@ -7,10 +7,7 @@ import { isMemberOf } from "../../utils";
const TTDDialogTabs = (
props: {
children: ReactNode;
} & (
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
),
} & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
) => {
const setAppState = useExcalidrawSetAppState();
@@ -39,13 +36,6 @@ const TTDDialogTabs = (
}
}
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" &&
isMemberOf(["text-to-diagram", "mermaid"], tab)
) {
@@ -202,7 +202,7 @@ const getRelevantAppStateProps = (
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingElement: appState.editingElement,
editingTextElement: appState.editingTextElement,
});
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,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
@@ -13,6 +13,7 @@ const DropdownMenuItemLink = ({
onSelect,
className = "",
selected,
rel = "noreferrer",
...rest
}: {
href: string;
@@ -22,6 +23,7 @@ const DropdownMenuItemLink = ({
className?: string;
selected?: boolean;
onSelect?: (event: Event) => void;
rel?: string;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
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 GRID_SIZE = 20; // TODO make it configurable?
export const DEFAULT_GRID_SIZE = 20;
export const DEFAULT_GRID_STEP = 5;
export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml",
@@ -234,7 +235,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 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;
// 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-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-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 (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
(local.id === localAppState.editingTextElement?.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 element is newer
+28 -16
View File
@@ -10,12 +10,7 @@ import type {
PointBinding,
StrokeRoundness,
} from "../element/types";
import type {
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import type { AppState, BinaryFiles, LibraryItem } from "../types";
import type { ImportedDataState, LegacyAppState } from "./types";
import {
getNonDeletedElements,
@@ -39,11 +34,17 @@ import {
ROUNDNESS,
DEFAULT_SIDEBAR,
DEFAULT_ELEMENT_PROPS,
DEFAULT_GRID_SIZE,
DEFAULT_GRID_STEP,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import {
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -51,6 +52,12 @@ import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts";
import { normalizeFixedPoint } from "../element/binding";
import {
getNormalizedGridSize,
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
type RestoredAppState = Omit<
AppState,
@@ -106,7 +113,7 @@ const repairBinding = (
...binding,
focus: binding.focus || 0,
fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number])
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
: null,
};
};
@@ -613,19 +620,24 @@ export const restoreAppState = (
locked: nextAppState.activeTool.locked ?? false,
},
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"
? {
value: appState.zoom as NormalizedZoomValue,
}
: appState.zoom?.value
? appState.zoom
: defaultAppState.zoom,
zoom: {
value: getNormalizedZoom(
isFiniteNumber(appState.zoom)
? appState.zoom
: appState.zoom?.value ?? defaultAppState.zoom.value,
),
},
openSidebar:
// string (legacy)
typeof (appState.openSidebar as any as string) === "string"
? { name: DEFAULT_SIDEBAR.name }
: nextAppState.openSidebar,
gridSize: getNormalizedGridSize(
isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
),
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
};
};
+30 -13
View File
@@ -41,6 +41,7 @@ import {
isElbowArrow,
isFrameLikeElement,
isLinearElement,
isRectangularElement,
isTextElement,
} from "./typeChecks";
import type { ElementUpdate } from "./mutateElement";
@@ -71,6 +72,7 @@ import {
vectorToHeading,
type Heading,
} from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -751,7 +753,8 @@ export const bindPointToSnapToElementOutline = (
const aabb = bindableElement && aabbForElement(bindableElement);
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 = [
...intersectElementWithLine(
bindableElement,
@@ -767,18 +770,14 @@ export const bindPointToSnapToElementOutline = (
FIXED_BINDING_DISTANCE,
elementsMap,
),
].map((i) =>
distanceToBindableElement(bindableElement, i, elementsMap) >
Math.min(bindableElement.width, bindableElement.height) / 2
? ([-1 * i[0], -1 * i[1]] as Point)
: i,
);
];
const heading = headingForPointFromElement(bindableElement, aabb, point);
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = distanceToBindableElement(bindableElement, point, elementsMap);
const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap),
);
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
@@ -1000,7 +999,7 @@ const updateBoundPoint = (
if (isElbowArrow(linearElement)) {
const fixedPoint =
binding.fixedPoint ??
normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding(
linearElement,
bindableElement,
@@ -1113,12 +1112,12 @@ export const calculateFixedPointForElbowArrowBinding = (
) as Point;
return {
fixedPoint: [
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
] as [number, number],
]),
};
};
@@ -1605,6 +1604,10 @@ const intersectElementWithLine = (
gap: number = 0,
elementsMap: ElementsMap,
): Point[] => {
if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap);
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
@@ -2171,7 +2174,8 @@ export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
) => {
const [fixedX, fixedY] = fixedPointRatio;
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element),
@@ -2225,3 +2229,16 @@ export const getArrowLocalFixedPoints = (
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
];
};
export const normalizeFixedPoint = <T extends FixedPoint | null>(
fixedPoint: T,
): T extends null ? null : FixedPoint => {
// Do not allow a precise 0.5 for fixed point ratio
// to avoid jumping arrow heading due to floating point imprecision
if (fixedPoint && (fixedPoint[0] === 0.5 || fixedPoint[1] === 0.5)) {
return fixedPoint.map((ratio) =>
ratio === 0.5 ? 0.5001 : ratio,
) as T extends null ? null : FixedPoint;
}
return fixedPoint as any as T extends null ? null : FixedPoint;
};
+3 -2
View File
@@ -738,6 +738,7 @@ export const getElementBounds = (
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap,
): Bounds => {
if (!elements.length) {
return [0, 0, 0, 0];
@@ -748,10 +749,10 @@ export const getCommonBounds = (
let minY = Infinity;
let maxY = -Infinity;
const elementsMap = arrayToMap(elements);
const _elementsMap = elementsMap || arrayToMap(elements);
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap);
minX = Math.min(minX, x1);
minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2);
+51 -26
View File
@@ -4,7 +4,12 @@ import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
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 { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
@@ -26,7 +31,7 @@ export const dragSelectedElements = (
x: number;
y: number;
},
gridSize: AppState["gridSize"],
gridSize: NullableGridSize,
) => {
if (
_selectedElements.length === 1 &&
@@ -101,7 +106,7 @@ const calculateOffset = (
commonBounds: Bounds,
dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"],
gridSize: NullableGridSize,
): { x: number; y: number } => {
const [x, y] = commonBounds;
let nextX = x + dragOffset.x + snapOffset.x;
@@ -154,26 +159,42 @@ export const getDragOffsetXY = (
return [x - x1, y - y1];
};
export const dragNewElement = (
newElement: NonDeletedExcalidrawElement,
elementType: AppState["activeTool"]["type"],
originX: number,
originY: number,
x: number,
y: number,
width: number,
height: number,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
zoom: NormalizedZoomValue,
export const dragNewElement = ({
newElement,
elementType,
originX,
originY,
x,
y,
width,
height,
shouldMaintainAspectRatio,
shouldResizeFromCenter,
zoom,
widthAspectRatio = null,
originOffset = null,
informMutation = true,
}: {
newElement: NonDeletedExcalidrawElement;
elementType: AppState["activeTool"]["type"];
originX: number;
originY: number;
x: number;
y: number;
width: number;
height: number;
shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null,
originOffset: {
widthAspectRatio?: number | null;
originOffset?: {
x: number;
y: number;
} | null = null,
) => {
} | null;
informMutation?: boolean;
}) => {
if (shouldMaintainAspectRatio && newElement.type !== "selection") {
if (widthAspectRatio) {
height = width / widthAspectRatio;
@@ -237,12 +258,16 @@ export const dragNewElement = (
}
if (width !== 0 && height !== 0) {
mutateElement(newElement, {
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
height,
...textAutoResize,
});
mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
height,
...textAutoResize,
},
informMutation,
);
}
};
@@ -0,0 +1,404 @@
import ReactDOM from "react-dom";
import { render } from "../tests/test-utils";
import { reseed } from "../random";
import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { KEYS } from "../keys";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const { h } = window;
const mouse = new Pointer("mouse");
beforeEach(async () => {
localStorage.clear();
reseed(7);
mouse.reset();
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.state.width = 1000;
h.state.height = 1000;
// The bounds of hand-drawn linear elements may change after flipping, so
// removing this style for testing
UI.clickTool("arrow");
UI.clickByTitle("Architect");
UI.clickTool("selection");
});
describe("flow chart creation", () => {
beforeEach(() => {
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
});
// multiple at once
it("create multiple successor nodes at once", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(5);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
});
it("when directions are changed, only the last same directions will apply", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_UP);
Keyboard.keyPress(KEYS.ARROW_UP);
Keyboard.keyPress(KEYS.ARROW_UP);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(7);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
});
it("when escaped, no nodes will be created", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_UP);
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyPress(KEYS.ESCAPE);
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(1);
});
it("create nodes one at a time", () => {
const initialNode = h.elements[0];
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(3);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
const firstChildNode = h.elements.filter(
(el) => el.type === "rectangle" && el.id !== initialNode.id,
)[0];
expect(firstChildNode).not.toBe(null);
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
API.setSelectedElements([initialNode]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(5);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
const secondChildNode = h.elements.filter(
(el) =>
el.type === "rectangle" &&
el.id !== initialNode.id &&
el.id !== firstChildNode.id,
)[0];
expect(secondChildNode).not.toBe(null);
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
API.setSelectedElements([initialNode]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(7);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
const thirdChildNode = h.elements.filter(
(el) =>
el.type === "rectangle" &&
el.id !== initialNode.id &&
el.id !== firstChildNode.id &&
el.id !== secondChildNode.id,
)[0];
expect(thirdChildNode).not.toBe(null);
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
expect(firstChildNode.x).toBe(secondChildNode.x);
expect(secondChildNode.x).toBe(thirdChildNode.x);
});
});
describe("flow chart navigation", () => {
it("single node at each level", () => {
/**
* -> -> -> ->
*/
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
// all the way to the left, gets us to the first node
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
// all the way to the right, gets us to the last node
const rightMostNode = h.elements[h.elements.length - 2];
expect(rightMostNode);
expect(rightMostNode.type).toBe("rectangle");
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
});
it("multiple nodes at each level", () => {
/**
* from the perspective of the first node, there're four layers, and
* there are four nodes at the second layer
*
* ->
* -> -> -> ->
* ->
* ->
*/
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
const secondNode = h.elements[1];
const rightMostNode = h.elements[h.elements.length - 2];
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
API.setSelectedElements([rectangle]);
// because of same level cycling,
// going right five times should take us back to the second node again
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
// from the second node, going right three times should take us to the rightmost node
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
});
it("take the most obvious link when possible", () => {
/**
*
*
*
*/
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
// last node should be the one that's selected
const rightMostNode = h.elements[h.elements.length - 2];
expect(rightMostNode.type).toBe("rectangle");
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
// going any direction takes us to the predecessor as well
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
expect(predecessorToRightMostNode.type).toBe("rectangle");
API.setSelectedElements([rightMostNode]);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
true,
);
API.setSelectedElements([rightMostNode]);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
true,
);
API.setSelectedElements([rightMostNode]);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
true,
);
});
});
+698
View File
@@ -0,0 +1,698 @@
import {
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
compareHeading,
headingForPointFromElement,
type Heading,
} from "./heading";
import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "../math";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFlowchartNodeElement,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
} from "./types";
import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements, Point } from "../types";
import { mutateElement } from "./mutateElement";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
import {
isBindableElement,
isElbowArrow,
isFrameElement,
isFlowchartNodeElement,
} from "./typeChecks";
import { invariant } from "../utils";
type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100;
const HORIZONTAL_OFFSET = 100;
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
switch (key) {
case KEYS.ARROW_UP:
return "up";
case KEYS.ARROW_DOWN:
return "down";
case KEYS.ARROW_RIGHT:
return "right";
case KEYS.ARROW_LEFT:
return "left";
default:
return "right";
}
};
const getNodeRelatives = (
type: "predecessors" | "successors",
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) => {
const items = [...elementsMap.values()].reduce(
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
let oppositeBinding;
if (
isElbowArrow(el) &&
// we want check existence of the opposite binding, in the direction
// we're interested in
(oppositeBinding =
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
// similarly, we need to filter only arrows bound to target node
el[type === "predecessors" ? "endBinding" : "startBinding"]
?.elementId === node.id
) {
const relative = elementsMap.get(oppositeBinding.elementId);
if (!relative) {
return acc;
}
invariant(
isBindableElement(relative),
"not an ExcalidrawBindableElement",
);
const edgePoint: Point =
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
]);
acc.push({
relative,
heading,
});
}
return acc;
},
[],
);
switch (direction) {
case "up":
return items
.filter((item) => compareHeading(item.heading, HEADING_UP))
.map((item) => item.relative);
case "down":
return items
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
.map((item) => item.relative);
case "right":
return items
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
.map((item) => item.relative);
case "left":
return items
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
.map((item) => item.relative);
}
};
const getSuccessors = (
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) => {
return getNodeRelatives("successors", node, elementsMap, direction);
};
export const getPredecessors = (
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) => {
return getNodeRelatives("predecessors", node, elementsMap, direction);
};
const getOffsets = (
element: ExcalidrawFlowchartNodeElement,
linkedNodes: ExcalidrawElement[],
direction: LinkDirection,
) => {
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
// check if vertical space or horizontal space is available first
if (direction === "up" || direction === "down") {
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
// check vertical space
const minX = element.x;
const maxX = element.x + element.width;
// vertical space is available
if (
linkedNodes.every(
(linkedNode) =>
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
)
) {
return {
x: 0,
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
};
}
} else if (direction === "right" || direction === "left") {
const minY = element.y;
const maxY = element.y + element.height;
if (
linkedNodes.every(
(linkedNode) =>
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
)
) {
return {
x:
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
y: 0,
};
}
}
if (direction === "up" || direction === "down") {
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
const x =
linkedNodes.length === 0
? 0
: (linkedNodes.length + 1) % 2 === 0
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
if (direction === "up") {
return {
x,
y: y * -1,
};
}
return {
x,
y,
};
}
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
const x =
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
element.width;
const y =
linkedNodes.length === 0
? 0
: (linkedNodes.length + 1) % 2 === 0
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
if (direction === "left") {
return {
x: x * -1,
y,
};
}
return {
x,
y,
};
};
const addNewNode = (
element: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
) => {
const successors = getSuccessors(element, elementsMap, direction);
const predeccessors = getPredecessors(element, elementsMap, direction);
const offsets = getOffsets(
element,
[...successors, ...predeccessors],
direction,
);
const nextNode = newElement({
type: element.type,
x: element.x + offsets.x,
y: element.y + offsets.y,
// TODO: extract this to a util
width: element.width,
height: element.height,
roundness: element.roundness,
roughness: element.roughness,
backgroundColor: element.backgroundColor,
strokeColor: element.strokeColor,
strokeWidth: element.strokeWidth,
});
invariant(
isFlowchartNodeElement(nextNode),
"not an ExcalidrawFlowchartNodeElement",
);
const bindingArrow = createBindingArrow(
element,
nextNode,
elementsMap,
direction,
appState,
);
return {
nextNode,
bindingArrow,
};
};
export const addNewNodes = (
startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
numberOfNodes: number,
) => {
// always start from 0 and distribute evenly
const newNodes: ExcalidrawElement[] = [];
for (let i = 0; i < numberOfNodes; i++) {
let nextX: number;
let nextY: number;
if (direction === "left" || direction === "right") {
const totalHeight =
VERTICAL_OFFSET * (numberOfNodes - 1) +
numberOfNodes * startNode.height;
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
let offsetX = HORIZONTAL_OFFSET + startNode.width;
if (direction === "left") {
offsetX *= -1;
}
nextX = startNode.x + offsetX;
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
nextY = startY + offsetY;
} else {
const totalWidth =
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
numberOfNodes * startNode.width;
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
let offsetY = VERTICAL_OFFSET + startNode.height;
if (direction === "up") {
offsetY *= -1;
}
nextY = startNode.y + offsetY;
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
nextX = startX + offsetX;
}
const nextNode = newElement({
type: startNode.type,
x: nextX,
y: nextY,
// TODO: extract this to a util
width: startNode.width,
height: startNode.height,
roundness: startNode.roundness,
roughness: startNode.roughness,
backgroundColor: startNode.backgroundColor,
strokeColor: startNode.strokeColor,
strokeWidth: startNode.strokeWidth,
});
invariant(
isFlowchartNodeElement(nextNode),
"not an ExcalidrawFlowchartNodeElement",
);
const bindingArrow = createBindingArrow(
startNode,
nextNode,
elementsMap,
direction,
appState,
);
newNodes.push(nextNode);
newNodes.push(bindingArrow);
}
return newNodes;
};
const createBindingArrow = (
startBindingElement: ExcalidrawFlowchartNodeElement,
endBindingElement: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
direction: LinkDirection,
appState: AppState,
) => {
let startX: number;
let startY: number;
const PADDING = 6;
switch (direction) {
case "up": {
startX = startBindingElement.x + startBindingElement.width / 2;
startY = startBindingElement.y - PADDING;
break;
}
case "down": {
startX = startBindingElement.x + startBindingElement.width / 2;
startY = startBindingElement.y + startBindingElement.height + PADDING;
break;
}
case "right": {
startX = startBindingElement.x + startBindingElement.width + PADDING;
startY = startBindingElement.y + startBindingElement.height / 2;
break;
}
case "left": {
startX = startBindingElement.x - PADDING;
startY = startBindingElement.y + startBindingElement.height / 2;
break;
}
}
let endX: number;
let endY: number;
switch (direction) {
case "up": {
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
break;
}
case "down": {
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
endY = endBindingElement.y - startY - PADDING;
break;
}
case "right": {
endX = endBindingElement.x - startX - PADDING;
endY = endBindingElement.y - startY + endBindingElement.height / 2;
break;
}
case "left": {
endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
endY = endBindingElement.y - startY + endBindingElement.height / 2;
break;
}
}
const bindingArrow = newArrowElement({
type: "arrow",
x: startX,
y: startY,
startArrowhead: appState.currentItemStartArrowhead,
endArrowhead: appState.currentItemEndArrowhead,
strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth,
points: [
[0, 0],
[endX, endY],
],
elbowed: true,
});
bindLinearElement(
bindingArrow,
startBindingElement,
"start",
elementsMap as NonDeletedSceneElementsMap,
);
bindLinearElement(
bindingArrow,
endBindingElement,
"end",
elementsMap as NonDeletedSceneElementsMap,
);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
startBindingElement.id,
startBindingElement as OrderedExcalidrawElement,
);
changedElements.set(
endBindingElement.id,
endBindingElement as OrderedExcalidrawElement,
);
changedElements.set(
bindingArrow.id,
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(
bindingArrow,
[
{
index: 1,
point: bindingArrow.points[1],
},
],
elementsMap as NonDeletedSceneElementsMap,
undefined,
{
changedElements,
},
);
return bindingArrow;
};
export class FlowChartNavigator {
isExploring: boolean = false;
// nodes that are ONE link away (successor and predecessor both included)
private sameLevelNodes: ExcalidrawElement[] = [];
private sameLevelIndex: number = 0;
// set it to the opposite of the defalut creation direction
private direction: LinkDirection | null = null;
// for speedier navigation
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
clear() {
this.isExploring = false;
this.sameLevelNodes = [];
this.sameLevelIndex = 0;
this.direction = null;
this.visitedNodes.clear();
}
exploreByDirection(
element: ExcalidrawElement,
elementsMap: ElementsMap,
direction: LinkDirection,
): ExcalidrawElement["id"] | null {
if (!isBindableElement(element)) {
return null;
}
// clear if going at a different direction
if (direction !== this.direction) {
this.clear();
}
// add the current node to the visited
if (!this.visitedNodes.has(element.id)) {
this.visitedNodes.add(element.id);
}
/**
* CASE:
* - already started exploring, AND
* - there are multiple nodes at the same level, AND
* - still going at the same direction, AND
*
* RESULT:
* - loop through nodes at the same level
*
* WHY:
* - provides user the capability to loop through nodes at the same level
*/
if (
this.isExploring &&
direction === this.direction &&
this.sameLevelNodes.length > 1
) {
this.sameLevelIndex =
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
return this.sameLevelNodes[this.sameLevelIndex].id;
}
const nodes = [
...getSuccessors(element, elementsMap, direction),
...getPredecessors(element, elementsMap, direction),
];
/**
* CASE:
* - just started exploring at the given direction
*
* RESULT:
* - go to the first node in the given direction
*/
if (nodes.length > 0) {
this.sameLevelIndex = 0;
this.isExploring = true;
this.sameLevelNodes = nodes;
this.direction = direction;
this.visitedNodes.add(nodes[0].id);
return nodes[0].id;
}
/**
* CASE:
* - (just started exploring or still going at the same direction) OR
* - there're no nodes at the given direction
*
* RESULT:
* - go to some other unvisited linked node
*
* WHY:
* - provide a speedier navigation from a given node to some predecessor
* without the user having to change arrow key
*/
if (direction === this.direction || !this.isExploring) {
if (!this.isExploring) {
// just started and no other nodes at the given direction
// so the current node is technically the first visited node
// (this is needed so that we don't get stuck between looping through )
this.visitedNodes.add(element.id);
}
const otherDirections: LinkDirection[] = [
"up",
"right",
"down",
"left",
].filter((dir): dir is LinkDirection => dir !== direction);
const otherLinkedNodes = otherDirections
.map((dir) => [
...getSuccessors(element, elementsMap, dir),
...getPredecessors(element, elementsMap, dir),
])
.flat()
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
for (const linkedNode of otherLinkedNodes) {
if (!this.visitedNodes.has(linkedNode.id)) {
this.visitedNodes.add(linkedNode.id);
this.isExploring = true;
this.direction = direction;
return linkedNode.id;
}
}
}
return null;
}
}
export class FlowChartCreator {
isCreatingChart: boolean = false;
private numberOfNodes: number = 0;
private direction: LinkDirection | null = "right";
pendingNodes: PendingExcalidrawElements | null = null;
createNodes(
startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
) {
if (direction !== this.direction) {
const { nextNode, bindingArrow } = addNewNode(
startNode,
elementsMap,
appState,
direction,
);
this.numberOfNodes = 1;
this.isCreatingChart = true;
this.direction = direction;
this.pendingNodes = [nextNode, bindingArrow];
} else {
this.numberOfNodes += 1;
const newNodes = addNewNodes(
startNode,
elementsMap,
appState,
direction,
this.numberOfNodes,
);
this.isCreatingChart = true;
this.direction = direction;
this.pendingNodes = newNodes;
}
// add pending nodes to the same frame as the start node
// if every pending node is at least intersecting with the frame
if (startNode.frameId) {
const frame = elementsMap.get(startNode.frameId);
invariant(
frame && isFrameElement(frame),
"not an ExcalidrawFrameElement",
);
if (
frame &&
this.pendingNodes.every(
(node) =>
elementsAreInFrameBounds([node], frame, elementsMap) ||
elementOverlapsWithFrame(node, frame, elementsMap),
)
) {
this.pendingNodes = this.pendingNodes.map((node) =>
mutateElement(
node,
{
frameId: startNode.frameId,
},
false,
),
);
}
}
}
clear() {
this.isCreatingChart = false;
this.pendingNodes = null;
this.direction = null;
this.numberOfNodes = 0;
}
}
export const isNodeInFlowchart = (
element: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
) => {
for (const [, el] of elementsMap) {
if (
el.type === "arrow" &&
(el.startBinding?.elementId === element.id ||
el.endBinding?.elementId === element.id)
) {
return true;
}
}
return false;
};
+1 -1
View File
@@ -46,7 +46,7 @@ export {
dragNewElement,
} from "./dragElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks";
export { redrawTextBoundingBox } from "./textElement";
export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
export {
getPerfectElementSize,
getLockedLinearCursorAlignSize,
@@ -36,6 +36,8 @@ import type {
AppState,
PointerCoords,
InteractiveCanvasAppState,
AppClassProperties,
NullableGridSize,
} from "../types";
import { mutateElement } from "./mutateElement";
@@ -44,7 +46,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { tupleToCoors } from "../utils";
import { toBrandedType, tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
@@ -209,7 +211,7 @@ export class LinearElementEditor {
/** @returns whether point was dragged */
static handlePointDragging(
event: PointerEvent,
appState: AppState,
app: AppClassProperties,
scenePointerX: number,
scenePointerY: number,
maybeSuggestBinding: (
@@ -279,7 +281,7 @@ export class LinearElementEditor {
elementsMap,
referencePoint,
[scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(
@@ -299,7 +301,7 @@ export class LinearElementEditor {
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
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];
@@ -315,7 +317,7 @@ export class LinearElementEditor {
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
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,
@@ -695,7 +697,7 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent<HTMLElement>,
appState: AppState,
app: AppClassProperties,
store: Store,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
@@ -705,6 +707,7 @@ export class LinearElementEditor {
hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
} {
const appState = app.state;
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
@@ -741,7 +744,7 @@ export class LinearElementEditor {
}
if (event.altKey && appState.editingLinearElement) {
if (
linearElementEditor.lastUncommittedPoint == null ||
linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element)
) {
mutateElement(element, {
@@ -752,7 +755,7 @@ export class LinearElementEditor {
elementsMap,
scenePointer.x,
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>,
scenePointerX: number,
scenePointerY: number,
appState: AppState,
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) {
return null;
}
@@ -915,7 +919,7 @@ export class LinearElementEditor {
elementsMap,
lastCommittedPoint,
[scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
newPoint = [
@@ -930,7 +934,7 @@ export class LinearElementEditor {
scenePointerY - appState.editingLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: appState.gridSize,
: app.getEffectiveGridSize(),
);
}
@@ -1065,7 +1069,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap,
scenePointerX: number,
scenePointerY: number,
gridSize: number | null,
gridSize: NullableGridSize,
): Point {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -1363,7 +1367,7 @@ export class LinearElementEditor {
static addMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
app: AppClassProperties,
snapToGrid: boolean,
elementsMap: ElementsMap,
) {
@@ -1388,7 +1392,7 @@ export class LinearElementEditor {
elementsMap,
pointerCoords.x,
pointerCoords.y,
snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
);
const points = [
...element.points.slice(0, segmentMidpoint.index!),
@@ -1447,9 +1451,15 @@ export class LinearElementEditor {
: null;
}
const mergedElementsMap = options?.changedElements
? toBrandedType<SceneElementsMap>(
new Map([...elementsMap, ...options.changedElements]),
)
: elementsMap;
mutateElbowArrow(
element,
elementsMap,
mergedElementsMap,
nextPoints,
[offsetX, offsetY],
bindings,
@@ -1479,7 +1489,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap,
referencePoint: Point,
scenePointer: Point,
gridSize: number | null,
gridSize: NullableGridSize,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element,
+2 -1
View File
@@ -375,12 +375,13 @@ export const newFreeDrawElement = (
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [],
pressures: [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
};
+1 -16
View File
@@ -22,21 +22,6 @@ const { h } = window;
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", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
@@ -193,7 +178,7 @@ describe("elbow arrow ui", () => {
mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector(
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
UI.updateInput(inputAngle, String("40"));
@@ -9,7 +9,7 @@ export const showSelectedShapeActions = (
Boolean(
!appState.viewModeEnabled &&
((appState.activeTool.type !== "custom" &&
(appState.editingElement ||
(appState.editingTextElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" &&
+44 -1
View File
@@ -3,7 +3,7 @@ import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds";
import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils";
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
@@ -55,6 +55,49 @@ export const isElementInViewport = (
);
};
export const isElementCompletelyInViewport = (
elements: ExcalidrawElement[],
width: number,
height: number,
viewTransformations: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
elementsMap: ElementsMap,
padding?: Partial<{
top: number;
right: number;
bottom: number;
left: number;
}>,
) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft + (padding?.left || 0),
clientY: viewTransformations.offsetTop + (padding?.top || 0),
},
viewTransformations,
);
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
},
viewTransformations,
);
return (
x1 >= topLeftSceneCoords.x &&
y1 >= topLeftSceneCoords.y &&
x2 <= bottomRightSceneCoords.x &&
y2 <= bottomRightSceneCoords.y
);
};
/**
* Makes a perfect shape or diagonal/horizontal/vertical line
*/
@@ -886,3 +886,19 @@ export const getMinTextElementWidth = (
) => {
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);
expect(h.state.editingElement?.id).toBe(text.id);
expect(h.state.editingTextElement?.id).toBe(text.id);
expect(
(h.state.editingElement as ExcalidrawTextElement).containerId,
(h.state.editingTextElement as ExcalidrawTextElement).containerId,
).toBe(null);
});
@@ -105,7 +105,7 @@ describe("textWysiwyg", () => {
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", () => {
@@ -133,7 +133,7 @@ describe("textWysiwyg", () => {
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", () => {
@@ -174,7 +174,7 @@ describe("textWysiwyg", () => {
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 () => {
@@ -195,7 +195,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, false);
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);
});
@@ -217,7 +217,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, false);
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);
});
@@ -286,7 +286,7 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
const editor = await getTextEditor(textEditorSelector);
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);
const nextText = `${wrappedText} is great!`;
@@ -881,7 +881,7 @@ describe("textWysiwyg", () => {
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);
+10 -1
View File
@@ -357,7 +357,16 @@ export const textWysiwyg = ({
};
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);
};
}
+28
View File
@@ -24,6 +24,7 @@ import type {
ExcalidrawElbowArrowElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
} from "./types";
export const isInitializedImageElement = (
@@ -175,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 = (
element: ExcalidrawElement | null,
includeLocked = true,
@@ -219,6 +237,16 @@ export const isExcalidrawElement = (
}
};
export const isFlowchartNodeElement = (
element: ExcalidrawElement,
): element is ExcalidrawFlowchartNodeElement => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
);
};
export const hasBoundTextElement = (
element: ExcalidrawElement | null,
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
+17 -2
View File
@@ -12,7 +12,6 @@ import type {
Merge,
ValueOf,
} from "../utility-types";
import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@@ -101,11 +100,22 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
type: "embeddable";
}>;
export type MagicGenerationData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
Readonly<{
type: "iframe";
// TODO move later to AI-specific frame
customData?: { generationData?: MagicCacheData };
customData?: { generationData?: MagicGenerationData };
}>;
export type ExcalidrawIframeLikeElement =
@@ -160,6 +170,11 @@ export type ExcalidrawGenericElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawFlowchartNodeElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
@@ -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,
isInvisiblySmallElement,
getNonDeletedElements,
getTextFromElements,
} from "./element";
export { defaultLang, useI18n, languages } from "./i18n";
export {
@@ -271,6 +272,7 @@ export { MainMenu };
export { useDevice } from "./components/App";
export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats";
export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
@@ -286,3 +288,6 @@ export {
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
+10 -8
View File
@@ -168,6 +168,7 @@
"exportImage": "Export image...",
"export": "Save to...",
"copyToClipboard": "Copy to clipboard",
"copyLink": "Copy link",
"save": "Save to current file",
"saveAs": "Save as",
"load": "Open",
@@ -272,8 +273,7 @@
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings"
"mermaidToExcalidraw": "Mermaid to Excalidraw"
},
"element": {
"rectangle": "Rectangle",
@@ -316,6 +316,7 @@
"placeImage": "Click to place the image, or click and drag to set its size manually",
"publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text",
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
"eraserRevert": "Hold Alt to revert the elements marked for deletion",
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
@@ -366,6 +367,8 @@
"click": "click",
"deepSelect": "Deep select",
"deepBoxSelect": "Deep select within box, and prevent dragging",
"createFlowchart": "Create a flowchart from a generic element",
"navigateFlowchart": "Navigate a flowchart",
"curvedArrow": "Curved arrow",
"curvedLine": "Curved line",
"documentation": "Documentation",
@@ -459,16 +462,15 @@
},
"stats": {
"angle": "Angle",
"element": "Element",
"elements": "Elements",
"shapes": "Shapes",
"height": "Height",
"scene": "Scene",
"selected": "Selected",
"storage": "Storage",
"fullTitle": "Stats & Element properties",
"title": "Stats",
"generalStats": "General stats",
"elementProperties": "Element properties",
"fullTitle": "Canvas & Shape properties",
"title": "Properties",
"generalStats": "General",
"elementProperties": "Shape properties",
"total": "Total",
"version": "Version",
"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 {
DEFAULT_ADAPTIVE_RADIUS,
LINE_CONFIRM_THRESHOLD,
@@ -275,7 +280,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
export const getGridPoint = (
x: number,
y: number,
gridSize: number | null,
gridSize: NullableGridSize,
): [number, number] => {
if (gridSize) {
return [
@@ -703,3 +708,8 @@ export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
export const clamp = (value: number, min: number, max: number) => {
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)) {
const textElement = allElementsMap.get(appState.editingElement.id) as
if (
appState.editingTextElement &&
isTextElement(appState.editingTextElement)
) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement
| undefined;
if (textElement && !textElement.autoResize) {
@@ -894,7 +897,7 @@ const _renderInteractiveScene = ({
!appState.viewModeEnabled &&
showBoundingBox &&
// do not show transform handles when text is being edited
!isTextElement(appState.editingElement)
!isTextElement(appState.editingTextElement)
) {
renderTransformHandles(
context,
+21 -1
View File
@@ -35,6 +35,7 @@ import type {
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
PendingExcalidrawElements,
} from "../types";
import { getDefaultAppState } from "../appState";
import {
@@ -104,6 +105,7 @@ export const getRenderOpacity = (
element: ExcalidrawElement,
containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure,
pendingNodes: Readonly<PendingExcalidrawElements> | null,
) => {
// multiplying frame opacity with element opacity to combine them
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
@@ -113,6 +115,7 @@ export const getRenderOpacity = (
// (so that erasing always results in lower opacity than original)
if (
elementsPendingErasure.has(element.id) ||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
) {
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
@@ -196,7 +199,7 @@ const generateElementCanvas = (
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas => {
): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
const padding = getCanvasPadding(element);
@@ -207,6 +210,10 @@ const generateElementCanvas = (
zoom,
);
if (!width || !height) {
return null;
}
canvas.width = width;
canvas.height = height;
@@ -537,6 +544,10 @@ const generateElementWithCanvas = (
appState,
);
if (!elementWithCanvas) {
return null;
}
elementWithCanvasCache.set(element, elementWithCanvas);
return elementWithCanvas;
@@ -672,6 +683,7 @@ export const renderElement = (
element,
getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure,
renderConfig.pendingFlowchartNodes,
);
switch (element.type) {
@@ -738,6 +750,10 @@ export const renderElement = (
renderConfig,
appState,
);
if (!elementWithCanvas) {
return;
}
drawElementFromCanvas(
elementWithCanvas,
context,
@@ -877,6 +893,10 @@ export const renderElement = (
appState,
);
if (!elementWithCanvas) {
return;
}
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
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);
};
+61 -19
View File
@@ -31,53 +31,77 @@ import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils";
import { getBoundTextElement } from "../element/textElement";
const GridLineColor = {
Bold: "#dddddd",
Regular: "#e5e5e5",
} as const;
const strokeGrid = (
context: CanvasRenderingContext2D,
/** grid cell pixel size */
gridSize: number,
/** setting to 1 will disble bold lines */
gridStep: number,
scrollX: number,
scrollY: number,
zoom: Zoom,
width: number,
height: number,
) => {
const BOLD_LINE_FREQUENCY = 5;
const offsetX = (scrollX % gridSize) - gridSize;
const offsetY = (scrollY % gridSize) - gridSize;
enum GridLineColor {
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 actualGridSize = gridSize * zoom.value;
const spaceWidth = 1 / zoom.value;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
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) {
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.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(x, offsetY - gridSize);
context.lineTo(x, offsetY + height + gridSize * 2);
context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
context.stroke();
}
for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
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.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(offsetX - gridSize, y);
context.lineTo(offsetX + width + gridSize * 2, y);
context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
context.stroke();
}
context.restore();
@@ -199,10 +223,11 @@ const _renderStaticScene = ({
context.scale(appState.zoom.value, appState.zoom.value);
// Grid
if (renderGrid && appState.gridSize) {
if (renderGrid) {
strokeGrid(
context,
appState.gridSize,
appState.gridStep,
appState.scrollX,
appState.scrollY,
appState.zoom,
@@ -370,6 +395,23 @@ const _renderStaticScene = ({
console.error(error);
}
});
// render pending nodes for flowcharts
renderConfig.pendingFlowchartNodes?.forEach((element) => {
try {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
} catch (error) {
console.error(error);
}
});
};
/** throttled to animation framerate */
+20 -8
View File
@@ -1,6 +1,7 @@
import { isElementInViewport } from "../element/sizeHelpers";
import { isImageElement } from "../element/typeChecks";
import type {
ExcalidrawElement,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
} from "../element/types";
@@ -64,11 +65,13 @@ export class Renderer {
const getRenderableElements = ({
elements,
editingElement,
editingTextElement,
newElementId,
pendingImageElementId,
}: {
elements: readonly NonDeletedExcalidrawElement[];
editingElement: AppState["editingElement"];
editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
}) => {
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
// (it's rendered on remote only)
if (
!editingElement ||
editingElement.type !== "text" ||
element.id !== editingElement.id
!editingTextElement ||
editingTextElement.type !== "text" ||
element.id !== editingTextElement.id
) {
elementsMap.set(element.id, element);
}
@@ -105,7 +112,8 @@ export class Renderer {
scrollY,
height,
width,
editingElement,
editingTextElement,
newElementId,
pendingImageElementId,
// cache-invalidation nonce
sceneNonce: _sceneNonce,
@@ -117,7 +125,10 @@ export class Renderer {
scrollY: AppState["scrollY"];
height: AppState["height"];
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"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => {
@@ -125,7 +136,8 @@ export class Renderer {
const elementsMap = getRenderableElements({
elements,
editingElement,
editingTextElement,
newElementId,
pendingImageElementId,
});
+9 -1
View File
@@ -377,6 +377,10 @@ class Scene {
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -403,7 +407,11 @@ class Scene {
};
insertElements = (elements: ExcalidrawElement[]) => {
const index = elements[0].frameId
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
+1
View File
@@ -242,6 +242,7 @@ export const exportToCanvas = async (
// empty disables embeddable rendering
embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
},
});
+5 -1
View File
@@ -15,4 +15,8 @@ export {
getElementAtPosition,
getElementsAtPosition,
} 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 = (
elements: ElementsMapOrArray,
appState: Pick<AppState, "selectedElementIds" | "editingElement">,
appState: Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "newElement"
>,
) =>
appState.editingElement
? [appState.editingElement]
appState.editingTextElement
? [appState.editingTextElement]
: appState.newElement
? [appState.newElement]
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
+13
View File
@@ -16,6 +16,7 @@ import type {
SocketId,
UserIdleState,
Device,
PendingExcalidrawElements,
} from "../types";
import type { MakeBrand } from "../utility-types";
@@ -33,6 +34,7 @@ export type StaticCanvasRenderConfig = {
isExporting: boolean;
embedsValidationStatus: EmbedsValidationStatus;
elementsPendingErasure: ElementsPendingErasure;
pendingFlowchartNodes: PendingExcalidrawElements | null;
};
export type SVGRenderConfig = {
@@ -90,6 +92,17 @@ export type InteractiveSceneRenderConfig = {
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 = {
scrollX: number;
scrollY: number;
-5
View File
@@ -1,10 +1,5 @@
import { MIN_ZOOM } from "../constants";
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 = (
{
viewportX,
+40 -31
View File
@@ -19,7 +19,12 @@ import {
getSelectedElements,
getVisibleAndNonSelectedElements,
} from "./scene/selection";
import type { AppState, KeyboardModifiersObject, Point } from "./types";
import type {
AppClassProperties,
AppState,
KeyboardModifiersObject,
Point,
} from "./types";
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 = ({
event,
appState,
app,
selectedElements,
}: {
appState: AppState;
app: AppClassProperties;
event: KeyboardModifiersObject;
selectedElements: NonDeletedExcalidrawElement[];
}) => {
if (event) {
return (
(appState.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!appState.objectsSnapModeEnabled &&
(app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!app.state.objectsSnapModeEnabled &&
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") {
return false;
}
return appState.objectsSnapModeEnabled;
return app.state.objectsSnapModeEnabled;
};
export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
@@ -406,13 +414,13 @@ export const getVisibleGaps = (
const getGapSnaps = (
selectedElements: ExcalidrawElement[],
dragOffset: Vector2D,
appState: AppState,
app: AppClassProperties,
event: KeyboardModifiersObject,
nearestSnapsX: Snaps,
nearestSnapsY: Snaps,
minOffset: Vector2D,
) => {
if (!isSnappingEnabled({ appState, event, selectedElements })) {
if (!isSnappingEnabled({ app, event, selectedElements })) {
return [];
}
@@ -596,14 +604,14 @@ export const getReferenceSnapPoints = (
const getPointSnaps = (
selectedElements: ExcalidrawElement[],
selectionSnapPoints: Point[],
appState: AppState,
app: AppClassProperties,
event: KeyboardModifiersObject,
nearestSnapsX: Snaps,
nearestSnapsY: Snaps,
minOffset: Vector2D,
) => {
if (
!isSnappingEnabled({ appState, event, selectedElements }) ||
!isSnappingEnabled({ app, event, selectedElements }) ||
(selectedElements.length === 0 && selectionSnapPoints.length === 0)
) {
return [];
@@ -652,13 +660,14 @@ const getPointSnaps = (
export const snapDraggedElements = (
elements: ExcalidrawElement[],
dragOffset: Vector2D,
appState: AppState,
app: AppClassProperties,
event: KeyboardModifiersObject,
elementsMap: ElementsMap,
) => {
const appState = app.state;
const selectedElements = getSelectedElements(elements, appState);
if (
!isSnappingEnabled({ appState, event, selectedElements }) ||
!isSnappingEnabled({ app, event, selectedElements }) ||
selectedElements.length === 0
) {
return {
@@ -687,7 +696,7 @@ export const snapDraggedElements = (
getPointSnaps(
selectedElements,
selectionPoints,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -697,7 +706,7 @@ export const snapDraggedElements = (
getGapSnaps(
selectedElements,
dragOffset,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -732,7 +741,7 @@ export const snapDraggedElements = (
getElementsCorners(selectedElements, elementsMap, {
dragOffset: newDragOffset,
}),
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -742,7 +751,7 @@ export const snapDraggedElements = (
getGapSnaps(
selectedElements,
newDragOffset,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -1075,13 +1084,13 @@ export const snapResizingElements = (
selectedElements: ExcalidrawElement[],
// while using the original elements to appy dragOffset to calculate snaps
selectedOriginalElements: ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
event: KeyboardModifiersObject,
dragOffset: Vector2D,
transformHandle: MaybeTransformHandleType,
) => {
if (
!isSnappingEnabled({ event, selectedElements, appState }) ||
!isSnappingEnabled({ event, selectedElements, app }) ||
selectedElements.length === 0 ||
(selectedElements.length === 1 &&
!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 = {
x: snapDistance,
@@ -1160,7 +1169,7 @@ export const snapResizingElements = (
getPointSnaps(
selectedOriginalElements,
selectionSnapPoints,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -1193,7 +1202,7 @@ export const snapResizingElements = (
getPointSnaps(
selectedElements,
corners,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -1210,13 +1219,13 @@ export const snapResizingElements = (
export const snapNewElement = (
newElement: ExcalidrawElement,
appState: AppState,
app: AppClassProperties,
event: KeyboardModifiersObject,
origin: Vector2D,
dragOffset: Vector2D,
elementsMap: ElementsMap,
) => {
if (!isSnappingEnabled({ event, selectedElements: [newElement], appState })) {
if (!isSnappingEnabled({ event, selectedElements: [newElement], app })) {
return {
snapOffset: { x: 0, y: 0 },
snapLines: [],
@@ -1227,7 +1236,7 @@ export const snapNewElement = (
[origin.x + dragOffset.x, origin.y + dragOffset.y],
];
const snapDistance = getSnapDistance(appState.zoom.value);
const snapDistance = getSnapDistance(app.state.zoom.value);
const minOffset = {
x: snapDistance,
@@ -1240,7 +1249,7 @@ export const snapNewElement = (
getPointSnaps(
[newElement],
selectionSnapPoints,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -1265,7 +1274,7 @@ export const snapNewElement = (
getPointSnaps(
[newElement],
corners,
appState,
app,
event,
nearestSnapsX,
nearestSnapsY,
@@ -1282,12 +1291,12 @@ export const snapNewElement = (
export const getSnapLinesAtPointer = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
pointer: Vector2D,
event: KeyboardModifiersObject,
elementsMap: ElementsMap,
) => {
if (!isSnappingEnabled({ event, selectedElements: [], appState })) {
if (!isSnappingEnabled({ event, selectedElements: [], app })) {
return {
originOffset: { x: 0, y: 0 },
snapLines: [],
@@ -1297,11 +1306,11 @@ export const getSnapLinesAtPointer = (
const referenceElements = getVisibleAndNonSelectedElements(
elements,
[],
appState,
app.state,
elementsMap,
);
const snapDistance = getSnapDistance(appState.zoom.value);
const snapDistance = getSnapDistance(app.state.zoom.value);
const minOffset = {
x: snapDistance,
@@ -812,10 +812,10 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -831,7 +831,9 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"isLoading": false,
@@ -1015,10 +1017,10 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -1034,7 +1036,9 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -1228,10 +1232,10 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -1247,7 +1251,9 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -1556,10 +1562,10 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -1575,7 +1581,9 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -1884,10 +1892,10 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -1903,7 +1911,9 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -2097,10 +2107,10 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -2116,7 +2126,9 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -2334,10 +2346,10 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -2353,7 +2365,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -2632,10 +2646,10 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -2651,7 +2665,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -2998,10 +3014,10 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -3017,7 +3033,9 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -3470,10 +3488,10 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -3489,7 +3507,9 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -3790,10 +3810,10 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -3809,7 +3829,9 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -4110,10 +4132,10 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -4129,7 +4151,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -5293,10 +5317,10 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -5312,7 +5336,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -6417,10 +6443,10 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -6436,7 +6462,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`] = `
{
@@ -7349,10 +7377,10 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -7368,7 +7396,9 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"isLoading": false,
@@ -8258,10 +8288,10 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -8277,7 +8307,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"isLoading": false,
@@ -9149,10 +9181,10 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
@@ -9168,7 +9200,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"height": 100,
"isBindingEnabled": true,
"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 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`;
@@ -1,8 +1,8 @@
// 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,
"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,
"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,
"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,
"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,
"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",
"updated": 1,
"version": 4,
"versionNonce": 760410951,
"versionNonce": 1723083209,
"width": 100,
"x": 0,
"y": 0,
@@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow",
"updated": 1,
"version": 8,
"versionNonce": 23633383,
"versionNonce": 1604849351,
"width": 70,
"x": 30,
"y": 30,
@@ -106,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line",
"updated": 1,
"version": 8,
"versionNonce": 23633383,
"versionNonce": 1604849351,
"width": 70,
"x": 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)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -83,8 +85,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
@@ -116,8 +120,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -148,8 +154,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -184,8 +192,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
@@ -225,8 +235,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -245,8 +257,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -265,8 +279,10 @@ describe("Test dragCreate", () => {
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -292,8 +308,10 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -319,8 +337,10 @@ describe("Test dragCreate", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
@@ -232,8 +232,8 @@ describe("element locking", () => {
API.setElements([container, text]);
API.setSelectedElements([container]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).not.toBe(text.id);
expect(h.state.editingElement?.id).toBe(h.elements[1].id);
expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
});
it("should ignore locked text under cursor when clicked with text tool", () => {
@@ -253,9 +253,9 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
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.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", () => {
@@ -275,9 +275,9 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
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.state.editingElement?.id).toBe(h.elements[1].id);
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
});
it("locking should include bound text", () => {
@@ -348,9 +348,9 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
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.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", () => {
@@ -382,8 +382,8 @@ describe("element locking", () => {
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
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.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 { Excalidraw, Footer, MainMenu } from "../index";
import { queryByText, queryByTestId } from "@testing-library/react";
import { GRID_SIZE, THEME } from "../constants";
import { THEME } from "../constants";
import { t } from "../i18n";
import { useMemo } from "react";
@@ -91,7 +91,7 @@ describe("<Excalidraw/>", () => {
describe("Test gridModeEnabled prop", () => {
it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => {
const { container } = await render(<Excalidraw />);
expect(h.state.gridSize).toBe(null);
expect(h.state.gridModeEnabled).toBe(false);
expect(
container.getElementsByClassName("disable-zen-mode--visible").length,
@@ -103,14 +103,14 @@ describe("<Excalidraw/>", () => {
});
const contextMenu = document.querySelector(".context-menu");
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 () => {
const { container } = await render(
<Excalidraw gridModeEnabled={false} />,
);
expect(h.state.gridSize).toBe(null);
expect(h.state.gridModeEnabled).toBe(false);
expect(
container.getElementsByClassName("disable-zen-mode--visible").length,
@@ -122,7 +122,7 @@ describe("<Excalidraw/>", () => {
});
const contextMenu = document.querySelector(".context-menu");
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],
appState: {
viewBackgroundColor: "#ffffff",
gridSize: null,
gridModeEnabled: false,
},
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 = () => {
return GlobalTestState.renderResult.container.querySelector(
".Stats",
".exc-stats",
) 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(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
@@ -961,7 +961,7 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
@@ -2727,7 +2727,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement).not.toBeNull();
// Simulate remote update
API.updateScene({
@@ -2740,8 +2740,8 @@ describe("history", () => {
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(4);
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(3);
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull();

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