Compare commits

...
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e3d1dee9d0 build(deps): bump micromatch from 4.0.7 to 4.0.8 (#8450)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 00:37:49 +02:00
hocinoandGitHub e3f31df747 docs : update dead link on main-menu page (#8454) 2024-09-01 22:31:13 +00:00
David LuzarandGitHub 60e75406e0 refactor: remove unused env variable (#8457) 2024-09-02 00:25:12 +02:00
zsviczianandGitHub b396e07b90 fix: PropertiesPopover maxWidth changing fixed units to relative units (#8456) 2024-09-01 18:18:37 +02:00
2d1d84a47b fix: View mode wheel zooming does not work (#8452)
* Update App.tsx

* fix: prevent zooming browser over DOM

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-31 12:06:38 +02:00
ee30225062 feat: subset font glyphs for SVG export (#8384)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-30 22:26:06 +02:00
David LuzarandGitHub 16cae4fc07 feat: reset copyStatus on export dialog settings change (#8443) 2024-08-29 15:10:08 +02:00
David LuzarandGitHub 576bc0dbe5 feat: tweak copy button success animation (#8441) 2024-08-29 00:43:01 +02:00
David LuzarandGitHub 00af35c692 feat: enable panning/zoom while in wysiwyg (#8437) 2024-08-29 00:42:46 +02:00
Márk TolmácsandGitHub ea7c702cfc feat: Visual debugger (#8344)
Add visual debugger to the Excalidraw app (only).
2024-08-27 19:46:00 +02:00
26d2296578 fix: fixed copy to clipboard button (#8426)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-27 00:27:44 +02:00
zsviczianandGitHub afb68a6467 feat: improve elbow arrow keyboard move (#8392) 2024-08-26 15:58:54 +02:00
b459e5cfd2 fix: context menu does not work after after dragging on StatsDragInput (#8386)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-26 15:58:46 +02:00
David LuzarandGitHub 5facc0d6da fix: perf regression in getCommonBounds (#8429) 2024-08-26 15:57:28 +02:00
Paul TuneandGitHub 824ad603e1 docs: Update function call in @excalidraw/mermaid-to-excalidraw (#8420) 2024-08-26 13:20:29 +02:00
5e1ff7cafe perf: improve new element drawing (#8340)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-23 20:27:57 +02:00
David LuzarandGitHub b5d7f5b4ba feat: rewrite d2c to not require token (#8269) 2024-08-20 18:06:22 +02:00
David LuzarandGitHub fb4bb29aa5 fix: object snapping not working (#8381) 2024-08-15 18:48:25 +02:00
David LuzarandGitHub 3cfcc7b489 feat: split gridSize from enabled state & support custom gridStep (#8364) 2024-08-14 14:59:14 +02:00
David LuzarandGitHub 4320a3cf41 feat: improve zoom-to-content when creating flowchart (#8368) 2024-08-12 20:42:08 +02:00
Márk TolmácsandGitHub 8420e1aa13 fix: Reimplement rectangle intersection (#8367) 2024-08-12 19:19:16 +02:00
Márk TolmácsandGitHub 5daf1a1b4e fix: Round coordinates and sizes for rectangle intersection (#8366)
Round coordinates and sizes for rectangle intersection
2024-08-12 11:07:58 +02:00
David LuzarandGitHub 97981804d7 feat: Stats popup style tweaks (#8361) 2024-08-11 19:33:44 +02:00
f7b3befd0a fix: text content with tab characters act different in view/edit (#8336)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-08-09 20:20:36 +00:00
DDDDD12138andGitHub 7b2bee9746 chore: remove unused parameter (#8355) 2024-08-09 21:39:40 +02:00
David LuzarandGitHub 88014ace4a fix: drawing from 0-dimension canvas (#8356) 2024-08-09 21:36:04 +02:00
David LuzarandGitHub 87a9430809 fix: disable flowchart keybindings inside inputs (#8353) 2024-08-09 18:44:17 +02:00
Márk TolmácsandGitHub 99b91c46f7 fix: Yet more patching of intersect code (#8352)
* Yet more patching of intersect code
2024-08-09 17:33:12 +02:00
David LuzarandGitHub 1ea5b26f25 fix: missing act() in flowchart tests (#8354) 2024-08-09 17:27:02 +02:00
Clarence ChanandGitHub d5f4ee7b3f fix: z-index change by one causes app to freeze (#8314) 2024-08-09 15:26:08 +02:00
Márk TolmácsandGitHub 261304c1a4 fix: Patch over intersection calculation issue (#8350)
* Patch over intersection calculation issue
2024-08-09 14:40:57 +02:00
Márk TolmácsandGitHub 84398a7e5c fix: Point duplication in LEE on ALT+click (#8347) 2024-08-09 12:01:32 +02:00
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
139 changed files with 9567 additions and 2275 deletions
-2
View File
@@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
# put these in your .env.local, or make sure you don't commit! # put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on # must be lowercase `true` when turned on
# #
# whether to enable Service Workers in development
VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD= VITE_APP_DEV_DISABLE_LIVE_RELOAD=
@@ -133,7 +133,7 @@ function App() {
} }
``` ```
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items. Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
### MainMenu.Group ### MainMenu.Group
@@ -14,7 +14,7 @@ This API receives the mermaid syntax as the input, and resolves to skeleton Exca
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import { convertToExcalidrawElements} from "@excalidraw/excalidraw" import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
try { try {
const { elements, files } = await parseMermaid(mermaidSyntax: string, { const { elements, files } = await parseMermaidToExcalidraw(mermaidSyntax: string, {
fontSize: number, fontSize: number,
}); });
const excalidrawElements = convertToExcalidrawElements(elements); const excalidrawElements = convertToExcalidrawElements(elements);
+1 -1
View File
@@ -43,7 +43,7 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
// editor state (canvas config, preferences, ...) // editor state (canvas config, preferences, ...)
"appState": { "appState": {
"gridSize": null, "gridSize": 20,
"viewBackgroundColor": "#ffffff" "viewBackgroundColor": "#ffffff"
}, },
+40 -58
View File
@@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, StoreAction,
reconcileElements, reconcileElements,
@@ -121,6 +120,12 @@ import {
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector"; import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state"; import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
polyfill(); polyfill();
@@ -337,6 +342,8 @@ const ExcalidrawWrapper = () => {
resolvablePromise<ExcalidrawInitialDataState | null>(); resolvablePromise<ExcalidrawInitialDataState | null>();
} }
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
trackEvent("load", "frame", getFrame()); trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW // Delayed so that the app has a time to load the latest SW
@@ -362,6 +369,23 @@ const ExcalidrawWrapper = () => {
migrationAdapter: LibraryLocalStorageMigrationAdapter, migrationAdapter: LibraryLocalStorageMigrationAdapter,
}); });
const [, forceRefresh] = useState(false);
useEffect(() => {
if (import.meta.env.DEV) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
window.visualDebug = {
data: [],
};
} else {
delete window.visualDebug;
}
forceRefresh((prev) => !prev);
}
}, [excalidrawAPI]);
useEffect(() => { useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) { if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return; return;
@@ -622,6 +646,11 @@ const ExcalidrawWrapper = () => {
} }
}); });
} }
// Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
}
}; };
const [latestShareableLink, setLatestShareableLink] = useState<string | null>( const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
@@ -820,6 +849,7 @@ const ExcalidrawWrapper = () => {
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
theme={appTheme} theme={appTheme}
setTheme={(theme) => setAppTheme(theme)} setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/> />
<AppWelcomeScreen <AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen} onCollabDialogOpen={onCollabDialogOpen}
@@ -845,64 +875,9 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action> </OverwriteConfirmDialog.Action>
)} )}
</OverwriteConfirmDialog> </OverwriteConfirmDialog>
<AppFooter /> <AppFooter onChange={() => excalidrawAPI?.refresh()} />
<TTDDialog {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="collab-offline-warning">
@@ -1132,6 +1107,13 @@ const ExcalidrawWrapper = () => {
}, },
]} ]}
/> />
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw> </Excalidraw>
</div> </div>
); );
+28 -33
View File
@@ -9,6 +9,7 @@ import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types"; import type { UIAppState } from "../packages/excalidraw/types";
import { Stats } from "../packages/excalidraw";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
@@ -51,39 +52,33 @@ const CustomStats = (props: Props) => {
} }
return ( return (
<> <Stats.StatsRows order={-1}>
<tr> <Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
<th colSpan={2}>{t("stats.storage")}</th> <Stats.StatsRow
</tr> style={{ textAlign: "center", cursor: "pointer" }}
<tr> onClick={async () => {
<td>{t("stats.scene")}</td> try {
<td>{nFormatter(storageSizes.scene, 1)}</td> await copyTextToSystemClipboard(getVersion());
</tr> props.setToast(t("toast.copyToClipboard"));
<tr> } catch {}
<td>{t("stats.total")}</td> }}
<td>{nFormatter(storageSizes.total, 1)}</td> title={t("stats.versionCopy")}
</tr> >
<tr> {timestamp}
<th colSpan={2}>{t("stats.version")}</th> <br />
</tr> {hash}
<tr> </Stats.StatsRow>
<td
colSpan={2} <Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
style={{ textAlign: "center", cursor: "pointer" }} <Stats.StatsRow columns={2}>
onClick={async () => { <div>{t("stats.scene")}</div>
try { <div>{nFormatter(storageSizes.scene, 1)}</div>
await copyTextToSystemClipboard(getVersion()); </Stats.StatsRow>
props.setToast(t("toast.copyToClipboard")); <Stats.StatsRow columns={2}>
} catch {} <div>{t("stats.total")}</div>
}} <div>{nFormatter(storageSizes.total, 1)}</div>
title={t("stats.versionCopy")} </Stats.StatsRow>
> </Stats.StatsRows>
{timestamp}
<br />
{hash}
</td>
</tr>
</>
); );
}; };
+1
View File
@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab", LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme", LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
VERSION_DATA_STATE: "version-dataState", VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
+20 -14
View File
@@ -116,20 +116,26 @@ class Portal {
} }
} }
this.collab.excalidrawAPI.updateScene({ let isChanged = false;
elements: this.collab.excalidrawAPI const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
// this will signal collaborators to pull image data from server isChanged = true;
// (using mutation instead of newElementWith otherwise it'd break // this will signal collaborators to pull image data from server
// in-progress dragging) // (using mutation instead of newElementWith otherwise it'd break
return newElementWith(element, { status: "saved" }); // in-progress dragging)
} return newElementWith(element, { status: "saved" });
return element; }
}), return element;
storeAction: StoreAction.UPDATE, });
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
storeAction: StoreAction.UPDATE,
});
}
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);
broadcastScene = async ( broadcastScene = async (
-218
View File
@@ -1,218 +0,0 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils";
import { useI18n } from "../../packages/excalidraw/i18n";
import { KEYS } from "../../packages/excalidraw/keys";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import {
copyIcon,
playerPlayIcon,
playerStopFilledIcon,
share,
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { ReactComponent as CollabImage } from "../../packages/excalidraw/assets/lock.svg";
import "./RoomDialog.scss";
const getShareIcon = () => {
const navigator = window.navigator as any;
const isAppleBrowser = /Apple/.test(navigator.vendor);
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
if (isAppleBrowser) {
return shareIOS;
} else if (isWindowsBrowser) {
return shareWindows;
}
return share;
};
export type RoomModalProps = {
handleClose: () => void;
activeRoomLink: string;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
};
export const RoomModal = ({
activeRoomLink,
onRoomCreate,
onRoomDestroy,
setErrorMessage,
username,
onUsernameChange,
handleClose,
}: RoomModalProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator;
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
const shareRoomLink = async () => {
try {
await navigator.share({
title: t("roomDialog.shareTitle"),
text: t("roomDialog.shareTitle"),
url: activeRoomLink,
});
} catch (error: any) {
// Just ignore.
}
};
if (activeRoomLink) {
return (
<>
<h3 className="RoomDialog__active__header">
{t("labels.liveCollaboration")}
</h3>
<TextField
value={username}
placeholder="Your name"
label="Your name"
onChange={onUsernameChange}
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
/>
<div className="RoomDialog__active__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={activeRoomLink}
/>
{isShareSupported && (
<FilledButton
size="large"
variant="icon"
label="Share"
icon={getShareIcon()}
className="RoomDialog__active__share"
onClick={shareRoomLink}
/>
)}
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
icon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="RoomDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="RoomDialog__active__description">
<p>
<span
role="img"
aria-hidden="true"
className="RoomDialog__active__description__emoji"
>
🔒{" "}
</span>
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
</div>
<div className="RoomDialog__active__actions">
<FilledButton
size="large"
variant="outlined"
color="danger"
label={t("roomDialog.button_stopSession")}
icon={playerStopFilledIcon}
onClick={() => {
trackEvent("share", "room closed");
onRoomDestroy();
}}
/>
</div>
</>
);
}
return (
<>
<div className="RoomDialog__inactive__illustration">
<CollabImage />
</div>
<div className="RoomDialog__inactive__header">
{t("labels.liveCollaboration")}
</div>
<div className="RoomDialog__inactive__description">
<strong>{t("roomDialog.desc_intro")}</strong>
{t("roomDialog.desc_privacy")}
</div>
<div className="RoomDialog__inactive__start_session">
<FilledButton
size="large"
label={t("roomDialog.button_startSession")}
icon={playerPlayIcon}
onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
onRoomCreate();
}}
/>
</div>
</>
);
};
const RoomDialog = (props: RoomModalProps) => {
return (
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
<div className="RoomDialog">
<RoomModal {...props} />
</div>
</Dialog>
);
};
export default RoomDialog;
+159
View File
@@ -0,0 +1,159 @@
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
import {
DiagramToCodePlugin,
exportToBlob,
getTextFromElements,
MIME_TYPES,
TTDDialog,
} from "../../packages/excalidraw";
import { getDataURL } from "../../packages/excalidraw/data/blob";
import { safelyParseJSON } from "../../packages/excalidraw/utils";
export const AIComponents = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI;
}) => {
return (
<>
<DiagramToCodePlugin
generate={async ({ frame, children }) => {
const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
const textFromFrameChildren = getTextFromElements(children);
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/diagram-to-code/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
texts: textFromFrameChildren,
image: dataURL,
theme: appState.theme,
}),
},
);
if (!response.ok) {
const text = await response.text();
const errorJSON = safelyParseJSON(text);
if (!errorJSON) {
throw new Error(text);
}
if (errorJSON.statusCode === 429) {
return {
html: `<html>
<body style="margin: 0; text-align: center">
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
</br>
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,
};
}
throw new Error(errorJSON.message || text);
}
try {
const { html } = await response.json();
if (!html) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
} catch (error: any) {
throw new Error("Generation failed (invalid response)");
}
}}
/>
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
</>
);
};
+23 -19
View File
@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(() => { export const AppFooter = React.memo(
return ( ({ onChange }: { onChange: () => void }) => {
<Footer> return (
<div <Footer>
style={{ <div
display: "flex", style={{
gap: ".5rem", display: "flex",
alignItems: "center", gap: ".5rem",
}} alignItems: "center",
> }}
{isExcalidrawPlusSignedUser ? ( >
<ExcalidrawPlusAppLink /> {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
) : ( {isExcalidrawPlusSignedUser ? (
<EncryptedIcon /> <ExcalidrawPlusAppLink />
)} ) : (
</div> <EncryptedIcon />
</Footer> )}
); </div>
}); </Footer>
);
},
);
+20
View File
@@ -2,11 +2,13 @@ import React from "react";
import { import {
loginIcon, loginIcon,
ExcalLogo, ExcalLogo,
eyeIcon,
} from "../../packages/excalidraw/components/icons"; } from "../../packages/excalidraw/components/icons";
import type { Theme } from "../../packages/excalidraw/element/types"; import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList"; import { LanguageList } from "../app-language/LanguageList";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any; onCollabDialogOpen: () => any;
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
isCollabEnabled: boolean; isCollabEnabled: boolean;
theme: Theme | "system"; theme: Theme | "system";
setTheme: (theme: Theme | "system") => void; setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
<MainMenu> <MainMenu>
@@ -50,6 +53,23 @@ export const AppMainMenu: React.FC<{
> >
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink> </MainMenu.ItemLink>
{import.meta.env.DEV && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
} else {
window.visualDebug = { data: [] };
saveDebugState({ enabled: true });
}
props?.refresh();
}}
>
Visual Debug
</MainMenu.Item>
)}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme allowSystemTheme
+293
View File
@@ -0,0 +1,293 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils";
import type { LineSegment } from "../../packages/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "../../packages/excalidraw/renderer/helpers";
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
import {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import { isLineSegment } from "../../packages/excalidraw/element/typeChecks";
const renderLine = (
context: CanvasRenderingContext2D,
zoom: number,
segment: LineSegment,
color: string,
) => {
context.save();
context.strokeStyle = color;
context.beginPath();
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
context.stroke();
context.restore();
};
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
context.beginPath();
context.moveTo(-10 * zoom, -10 * zoom);
context.lineTo(10 * zoom, 10 * zoom);
context.moveTo(10 * zoom, -10 * zoom);
context.lineTo(-10 * zoom, 10 * zoom);
context.stroke();
context.save();
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
appState: AppState,
) => {
frame.forEach((el) => {
switch (true) {
case isLineSegment(el.data):
renderLine(context, appState.zoom.value, el.data, el.color);
break;
}
});
};
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
viewBackgroundColor: "transparent",
});
// Apply zoom
context.save();
context.translate(
appState.scrollX * appState.zoom.value,
appState.scrollY * appState.zoom.value,
);
renderOrigin(context, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
window.visualDebug?.data &&
window.visualDebug.data.length > 0
) {
// Render only one frame
const [idx] = debugFrameData();
render(window.visualDebug.data[idx], context, appState);
} else {
// Render all debug frames
window.visualDebug?.data.forEach((frame) => {
render(frame, context, appState);
});
}
if (window.visualDebug) {
window.visualDebug!.data =
window.visualDebug?.data.map((frame) =>
frame.filter((el) => el.permanent),
) ?? [];
}
};
const debugFrameData = (): [number, number] => {
const currentFrame = window.visualDebug?.currentFrame ?? 0;
const frameCount = window.visualDebug?.data.length ?? 0;
if (frameCount > 0) {
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
}
return [0, 0];
};
export const saveDebugState = (debug: { enabled: boolean }) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
JSON.stringify(debug),
);
} catch (error: any) {
console.error(error);
}
};
export const debugRenderer = throttleRAF(
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
_debugRenderer(canvas, appState, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
let debug;
try {
const savedDebugState = localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
);
if (savedDebugState) {
debug = JSON.parse(savedDebugState) as { enabled: boolean };
}
} catch (error: any) {
console.error(error);
}
return debug ?? { enabled: false };
};
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1)
) {
window.visualDebug!.currentFrame = 0;
}
window.visualDebug!.currentFrame += 1;
onChange();
}, [onChange]);
const moveBackward = useCallback(() => {
if (
!window.visualDebug?.currentFrame ||
isNaN(window.visualDebug?.currentFrame ?? -1) ||
window.visualDebug?.currentFrame < 1
) {
window.visualDebug!.currentFrame = 1;
}
window.visualDebug!.currentFrame -= 1;
onChange();
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
if (window.visualDebug) {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
onChange();
}, [onChange]);
return (
<>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={trashFrames}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{TrashIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={moveBackward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon flip />
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-forward"
aria-label="Move forward"
type="button"
onClick={reset}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
{CloseIcon}
</div>
</button>
<button
className="ToolIcon_type_button"
data-testid="debug-backward"
aria-label="Move backward"
type="button"
onClick={moveForward}
>
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled="false"
>
<ArrowheadArrowIcon />
</div>
</button>
</>
);
};
interface DebugCanvasProps {
appState: AppState;
scale: number;
}
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;
-23
View File
@@ -128,29 +128,6 @@
<script> <script>
window.EXCALIDRAW_ASSET_PATH = window.origin; window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now --> <!-- For Nunito only preload the latin range, which should be good enough for now -->
+2 -2
View File
@@ -58,8 +58,8 @@
font-size: 0.75rem; font-size: 0.75rem;
line-height: 110%; line-height: 110%;
background: var(--color-success-lighter); background: var(--color-success);
color: var(--color-success); color: var(--color-success-text);
& > svg { & > svg {
width: 0.875rem; width: 0.875rem;
+13 -23
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics"; import { trackEvent } from "../../packages/excalidraw/analytics";
import { getFrame } from "../../packages/excalidraw/utils"; import { getFrame } from "../../packages/excalidraw/utils";
@@ -14,7 +13,6 @@ import {
share, share,
shareIOS, shareIOS,
shareWindows, shareWindows,
tablerCheckIcon,
} from "../../packages/excalidraw/components/icons"; } from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField"; import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
@@ -24,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss"; import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
type OnExportToBackend = () => void; type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly"; type ShareDialogType = "share" | "collaborationOnly";
@@ -63,10 +62,11 @@ const ActiveRoomDialog = ({
handleClose: () => void; handleClose: () => void;
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false); const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const isShareSupported = "share" in navigator; const isShareSupported = "share" in navigator;
const { onCopy, copyStatus } = useCopyStatus();
const copyRoomLink = async () => { const copyRoomLink = async () => {
try { try {
@@ -130,26 +130,16 @@ const ActiveRoomDialog = ({
onClick={shareRoomLink} onClick={shareRoomLink}
/> />
)} )}
<Popover.Root open={justCopied}> <FilledButton
<Popover.Trigger asChild> size="large"
<FilledButton label={t("buttons.copyLink")}
size="large" icon={copyIcon}
label="Copy link" status={copyStatus}
icon={copyIcon} onClick={() => {
onClick={copyRoomLink} copyRoomLink();
/> onCopy();
</Popover.Trigger> }}
<Popover.Content />
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div> </div>
<div className="ShareDialog__active__description"> <div className="ShareDialog__active__description">
<p> <p>
+13 -2
View File
@@ -73,8 +73,8 @@ export default defineConfig({
}, },
workbox: { workbox: {
// Don't push fonts and locales to app precache // Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"], globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"), urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
@@ -108,6 +108,17 @@ export default defineConfig({
}, },
}, },
}, },
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
], ],
}, },
manifest: { manifest: {
+2
View File
@@ -39,6 +39,8 @@ Please add the latest change on the top under the correct section.
### Breaking Changes ### Breaking Changes
- Stats container CSS changed, so if you're using `renderCustomStats`, you may need to adjust your styles to retain the same layout.
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898) - `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes | | | Before `commitToHistory` | After `storeAction` | Notes |
+24 -18
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import type { AppState, NormalizedZoomValue } from "../types"; import type { AppState } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
@@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { clamp } from "../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -104,6 +105,8 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats, stats: appState.stats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
@@ -244,6 +247,7 @@ export const actionResetZoom = register({
const zoomValueToFitBoundsOnViewport = ( const zoomValueToFitBoundsOnViewport = (
bounds: SceneBounds, bounds: SceneBounds,
viewportDimensions: { width: number; height: number }, viewportDimensions: { width: number; height: number },
viewportZoomFactor: number = 1, // default to 1 if not provided
) => { ) => {
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
@@ -251,20 +255,21 @@ const zoomValueToFitBoundsOnViewport = (
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM), return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 0.7, viewportZoomFactor = 1,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
appState: Readonly<AppState>; appState: Readonly<AppState>;
@@ -289,13 +294,9 @@ export const zoomToFitBounds = ({
Math.min( Math.min(
appState.width / commonBoundsWidth, appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight, appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1)); ) * clamp(viewportZoomFactor, 0.1, 1);
// Apply clamping to newZoomValue to be between 10% and 3000% newZoomValue = getNormalizedZoom(newZoomValue);
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width; let appStateWidth = appState.width;
@@ -314,10 +315,14 @@ export const zoomToFitBounds = ({
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { newZoomValue = zoomValueToFitBoundsOnViewport(
width: appState.width, bounds,
height: appState.height, {
}); width: appState.width,
height: appState.height,
},
viewportZoomFactor,
);
const centerScroll = centerScrollOn({ const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY }, scenePoint: { x: centerX, y: centerY },
@@ -408,6 +413,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7,
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
@@ -10,7 +10,7 @@ import {
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index"; import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element"; import { getTextFromElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants"; import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -239,16 +239,8 @@ export const copyText = register({
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
try { try {
copyTextToSystemClipboard(text); copyTextToSystemClipboard(getTextFromElements(selectedElements));
} catch (e) { } catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed")); throw new Error(t("errors.copyToSystemClipboardFailed"));
} }
@@ -15,7 +15,7 @@ import {
import type { AppState } from "../types"; import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding"; import { fixBindingsAfterDuplication } from "../element/binding";
import type { ActionResult } from "./types"; import type { ActionResult } from "./types";
import { GRID_SIZE } from "../constants"; import { DEFAULT_GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
@@ -99,8 +99,8 @@ const duplicateElements = (
groupIdMap, groupIdMap,
element, element,
{ {
x: element.x + GRID_SIZE / 2, x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2, y: element.y + DEFAULT_GRID_SIZE / 2,
}, },
); );
duplicatedElementsMap.set(newElement.id, newElement); duplicatedElementsMap.set(newElement.id, newElement);
@@ -50,7 +50,6 @@ export const actionFinalize = register({
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
selectedLinearElement: null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
@@ -179,7 +178,7 @@ export const actionFinalize = register({
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingTextElement: null,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import type { History } from "../history"; import type { History } from "../history";
import { HistoryChangedEvent } from "../history"; import { HistoryChangedEvent } from "../history";
import type { AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants"; import { isWindows } from "../constants";
@@ -13,17 +13,19 @@ import type { Store } from "../store";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
const writeData = ( const executeHistoryAction = (
app: AppClassProperties,
appState: Readonly<AppState>, appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void, updater: () => [SceneElementsMap, AppState] | void,
): ActionResult => { ): ActionResult => {
if ( if (
!appState.multiElement && !appState.multiElement &&
!appState.resizingElement && !appState.resizingElement &&
!appState.editingElement && !appState.editingTextElement &&
!appState.newElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
!appState.selectionElement !appState.selectionElement &&
!app.flowChartCreator.isCreatingChart
) { ) {
const result = updater(); const result = updater();
@@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => perform: (elements, appState, value, app) =>
writeData(appState, () => executeHistoryAction(app, appState, () =>
history.undo( history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState, appState,
@@ -94,7 +96,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState, _, app) => perform: (elements, appState, _, app) =>
writeData(appState, () => executeHistoryAction(app, appState, () =>
history.redo( history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState, appState,
@@ -133,7 +133,7 @@ export const changeProperty = (
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || selectedElementIds.get(element.id) ||
element.id === appState.editingElement?.id element.id === appState.editingTextElement?.id
) { ) {
return callback(element); return callback(element);
} }
@@ -148,13 +148,13 @@ export const getFormValue = function <T extends Primitive>(
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingElement = appState.editingElement; const editingTextElement = appState.editingTextElement;
const nonDeletedElements = getNonDeletedElements(elements); const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null; let ret: T | null = null;
if (editingElement) { if (editingTextElement) {
ret = getAttribute(editingElement); ret = getAttribute(editingTextElement);
} }
if (!ret) { if (!ret) {
@@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawElement | null ExcalidrawElement | null
>(); >();
let uniqueGlyphs = new Set<string>(); let uniqueChars = new Set<string>();
let skipFontFaceCheck = false; let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values()); const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
} }
if (!skipFontFaceCheck) { if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([ uniqueChars = new Set([
...uniqueGlyphs, ...uniqueChars,
...Array.from(newElement.originalText), ...Array.from(newElement.originalText),
]); ]);
} }
@@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({ const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily, fontFamily: nextFontFamily,
})}`; })}`;
const glyphs = Array.from(uniqueGlyphs.values()).join(); const chars = Array.from(uniqueChars.values()).join();
if ( if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded // we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) { for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw // trigger synchronous redraw
@@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({
); );
} }
} else { } else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => { window.document.fonts.load(fontString, chars).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) { for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts) // use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id); const latestElement = app.scene.getElement(element.id);
@@ -1076,19 +1073,20 @@ export const actionChangeFontFamily = register({
// open, populate the cache from scratch // open, populate the cache from scratch
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
const { editingElement } = appState; const { editingTextElement } = appState;
if (editingElement?.type === "text") { // still check type to be safe
// retrieve the latest version from the scene, as `editingElement` isn't mutated if (editingTextElement?.type === "text") {
const latestEditingElement = app.scene.getElement( // retrieve the latest version from the scene, as `editingTextElement` isn't mutated
editingElement.id, const latesteditingTextElement = app.scene.getElement(
editingTextElement.id,
); );
// inside the wysiwyg editor // inside the wysiwyg editor
cachedElementsRef.current.set( cachedElementsRef.current.set(
editingElement.id, editingTextElement.id,
newElementWith( newElementWith(
latestEditingElement || editingElement, latesteditingTextElement || editingTextElement,
{}, {},
true, true,
), ),
@@ -1,6 +1,5 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { GRID_SIZE } from "../constants";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { gridIcon } from "../components/icons"; import { gridIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -13,21 +12,21 @@ export const actionToggleGridMode = register({
viewMode: true, viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.gridSize, predicate: (appState) => appState.gridModeEnabled,
}, },
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {
...appState, ...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE, gridModeEnabled: !this.checked!(appState),
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => { predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined"; return props.gridModeEnabled === undefined;
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
}); });
@@ -17,7 +17,7 @@ export const actionToggleObjectsSnapMode = register({
appState: { appState: {
...appState, ...appState,
objectsSnapModeEnabled: !this.checked!(appState), objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null, gridModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a // place here categories that you want to track. We want to track just a
// small subset of categories at a given time. // small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]); const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
export const trackEvent = ( export const trackEvent = (
category: string, category: string,
+9 -3
View File
@@ -5,9 +5,11 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES, EXPORT_SCALES,
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP,
} from "./constants"; } from "./constants";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@@ -42,7 +44,7 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
newElement: null, newElement: null,
editingElement: null, editingTextElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
activeTool: { activeTool: {
@@ -59,7 +61,9 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false, exportEmbedScene: false,
exportWithDarkMode: false, exportWithDarkMode: false,
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: DEFAULT_GRID_SIZE,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true, isBindingEnabled: true,
defaultSidebarDockedPreference: false, defaultSidebarDockedPreference: false,
isLoading: false, isLoading: false,
@@ -161,7 +165,7 @@ const APP_STATE_STORAGE_CONF = (<
cursorButton: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false }, newElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
@@ -174,6 +178,8 @@ const APP_STATE_STORAGE_CONF = (<
exportWithDarkMode: { browser: true, export: false, server: false }, exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false }, fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
gridStep: { browser: true, export: true, server: true },
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
+7 -19
View File
@@ -45,11 +45,11 @@ import {
frameToolIcon, frameToolIcon,
mermaidLogoIcon, mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
OpenAIIcon,
MagicIcon, MagicIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants";
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
@@ -104,7 +104,9 @@ export const SelectedShapeActions = ({
) { ) {
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const isEditing = Boolean(appState.editingElement); const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const device = useDevice(); const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@@ -234,7 +236,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
)} )}
{!isEditing && targetElements.length > 0 && ( {!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -400,7 +402,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.mermaidToExcalidraw")} {t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.props.aiEnabled !== false && ( {app.props.aiEnabled !== false && app.plugins.diagramToCode && (
<> <>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
@@ -410,20 +412,6 @@ export const ShapesSwitcher = ({
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge> <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({
name: "settings",
source: "settings",
tab: "diagram-to-code",
});
}}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</DropdownMenu.Item>
</> </>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
@@ -439,7 +427,7 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
zoom: Zoom; zoom: Zoom;
}) => ( }) => (
<Stack.Col gap={1} className="zoom-actions"> <Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
<Stack.Row align="center"> <Stack.Row align="center">
{renderAction("zoomOut")} {renderAction("zoomOut")}
{renderAction("resetZoom")} {renderAction("resetZoom")}
File diff suppressed because it is too large Load Diff
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
return ( return (
<PropertiesPopover <PropertiesPopover
container={container} container={container}
style={{ maxWidth: "208px" }} style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); focusPickerContent();
@@ -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;
};
@@ -1,5 +1,19 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
@keyframes successStatusAnimation {
0% {
transform: scale(0.35);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}
.excalidraw { .excalidraw {
.ExcButton { .ExcButton {
--text-color: transparent; --text-color: transparent;
@@ -16,11 +30,20 @@
.Spinner { .Spinner {
--spinner-color: var(--color-surface-lowest); --spinner-color: var(--color-surface-lowest);
position: absolute;
visibility: visible;
} }
&[disabled] { .ExcButton__statusIcon {
visibility: visible;
position: absolute;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
}
&.ExcButton--status-loading,
&.ExcButton--status-success {
pointer-events: none; pointer-events: none;
.ExcButton__contents { .ExcButton__contents {
@@ -28,6 +51,10 @@
} }
} }
&[disabled] {
pointer-events: none;
}
&, &,
&__contents { &__contents {
display: flex; display: flex;
@@ -119,6 +146,46 @@
} }
} }
&--color-success {
&.ExcButton--variant-filled {
--text-color: var(--color-success-text);
--back-color: var(--color-success);
.Spinner {
--spinner-color: var(--color-success);
}
&:hover {
--back-color: var(--color-success-darker);
}
&:active {
--back-color: var(--color-success-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-success-contrast);
--border-color: var(--color-success-contrast);
--back-color: transparent;
.Spinner {
--spinner-color: var(--color-success-contrast);
}
&:hover {
--text-color: var(--color-success-contrast-hover);
--border-color: var(--color-success-contrast-hover);
}
&:active {
--text-color: var(--color-success-contrast-active);
--border-color: var(--color-success-contrast-active);
}
}
}
&--color-muted { &--color-muted {
&.ExcButton--variant-filled { &.ExcButton--variant-filled {
--text-color: var(--island-bg-color); --text-color: var(--island-bg-color);
@@ -5,9 +5,15 @@ import "./FilledButton.scss";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { isPromiseLike } from "../utils"; import { isPromiseLike } from "../utils";
import { tablerCheckIcon } from "./icons";
export type ButtonVariant = "filled" | "outlined" | "icon"; export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger" | "warning" | "muted"; export type ButtonColor =
| "primary"
| "danger"
| "warning"
| "muted"
| "success";
export type ButtonSize = "medium" | "large"; export type ButtonSize = "medium" | "large";
export type FilledButtonProps = { export type FilledButtonProps = {
@@ -15,6 +21,7 @@ export type FilledButtonProps = {
children?: React.ReactNode; children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
status?: null | "loading" | "success";
variant?: ButtonVariant; variant?: ButtonVariant;
color?: ButtonColor; color?: ButtonColor;
@@ -37,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
size = "medium", size = "medium",
fullWidth, fullWidth,
className, className,
status,
}, },
ref, ref,
) => { ) => {
@@ -46,8 +54,11 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
const ret = onClick?.(event); const ret = onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
try { // delay loading state to prevent flicker in case of quick response
const timer = window.setTimeout(() => {
setIsLoading(true); setIsLoading(true);
}, 50);
try {
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
@@ -56,11 +67,15 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
console.warn(error); console.warn(error);
} }
} finally { } finally {
clearTimeout(timer);
setIsLoading(false); setIsLoading(false);
} }
} }
}; };
const _status = isLoading ? "loading" : status;
color = _status === "success" ? "success" : color;
return ( return (
<button <button
className={clsx( className={clsx(
@@ -68,6 +83,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`, `ExcButton--color-${color}`,
`ExcButton--variant-${variant}`, `ExcButton--variant-${variant}`,
`ExcButton--size-${size}`, `ExcButton--size-${size}`,
`ExcButton--status-${_status}`,
{ "ExcButton--fullWidth": fullWidth }, { "ExcButton--fullWidth": fullWidth },
className, className,
)} )}
@@ -75,10 +91,16 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={isLoading} disabled={_status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{isLoading && <Spinner />} {_status === "loading" ? (
<Spinner className="ExcButton__statusIcon" />
) : (
_status === "success" && (
<div className="ExcButton__statusIcon">{tablerCheckIcon}</div>
)
)}
{icon && ( {icon && (
<div className="ExcButton__icon" aria-hidden> <div className="ExcButton__icon" aria-hidden>
{icon} {icon}
@@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--editor" className="HelpDialog__island--editor"
caption={t("helpDialog.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 <Shortcut
label={t("labels.moveCanvas")} label={t("labels.moveCanvas")}
shortcuts={[ shortcuts={[
@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
left: 0; left: 0;
top: 100%; top: 100%;
+35 -7
View File
@@ -1,6 +1,7 @@
import { t } from "../i18n"; import { t } from "../i18n";
import type { AppClassProperties, Device, UIAppState } from "../types"; import type { AppClassProperties, Device, UIAppState } from "../types";
import { import {
isFlowchartNodeElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isTextBindableContainer, isTextBindableContainer,
@@ -10,6 +11,8 @@ import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
@@ -18,7 +21,12 @@ interface HintViewerProps {
app: AppClassProperties; 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 { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
@@ -79,7 +87,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.text_selected"); return t("hints.text_selected");
} }
if (appState.editingElement && isTextElement(appState.editingElement)) { if (appState.editingTextElement) {
return t("hints.text_editing"); return t("hints.text_editing");
} }
@@ -87,13 +95,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if ( if (
appState.selectionElement && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.editingElement && !appState.editingTextElement &&
!appState.editingLinearElement !appState.editingLinearElement
) { ) {
return t("hints.deepBoxSelect"); return t("hints.deepBoxSelect");
} }
if (appState.gridSize && appState.selectedElementsAreBeingDragged) { if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping"); return t("hints.disableSnapping");
} }
@@ -115,6 +123,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0]) 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"); return t("hints.bindTextToElement");
} }
} }
@@ -129,17 +150,24 @@ export const HintViewer = ({
device, device,
app, app,
}: HintViewerProps) => { }: HintViewerProps) => {
let hint = getHints({ const hints = getHints({
appState, appState,
isMobile, isMobile,
device, device,
app, app,
}); });
if (!hint) {
if (!hints) {
return null; return null;
} }
hint = getShortcutKey(hint); const hint = Array.isArray(hints)
? hints
.map((hint) => {
return getShortcutKey(hint).replace(/\. ?$/, "");
})
.join(". ")
: getShortcutKey(hints);
return ( return (
<div className="HintViewer"> <div className="HintViewer">
@@ -35,6 +35,7 @@ import "./ImageExportDialog.scss";
import { FilledButton } from "./FilledButton"; import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils"; import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data"; import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -89,6 +90,21 @@ const ImageExportModal = ({
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
useEffect(() => {
// if user changes setting right after export to clipboard, reset the status
// so they don't have to wait for the timeout to click the button again
resetCopyStatus();
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportScale,
embedScene,
resetCopyStatus,
]);
const { exportedElements, exportingFrame } = prepareElementsForExport( const { exportedElements, exportingFrame } = prepareElementsForExport(
elementsSnapshot, elementsSnapshot,
appStateSnapshot, appStateSnapshot,
@@ -105,6 +121,7 @@ const ImageExportModal = ({
if (!maxWidth) { if (!maxWidth) {
return; return;
} }
exportToCanvas({ exportToCanvas({
elements: exportedElements, elements: exportedElements,
appState: { appState: {
@@ -294,11 +311,17 @@ const ImageExportModal = ({
<FilledButton <FilledButton
className="ImageExportModal__settings__buttons__button" className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")} label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() => status={copyStatus}
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, { onClick={async () => {
exportingFrame, await onExportImage(
}) EXPORT_IMAGE_TYPES.clipboard,
} exportedElements,
{
exportingFrame,
},
);
onCopy();
}}
icon={copyIcon} icon={copyIcon}
> >
{t("imageExportDialog.button.copyPngToClipboard")} {t("imageExportDialog.button.copyPngToClipboard")}
@@ -27,99 +27,6 @@
& > * { & > * {
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
} }
& > .Stats {
width: 204px;
position: absolute;
top: 60px;
font-size: 12px;
z-index: var(--zIndex-layerUI);
pointer-events: var(--ui-pointerEvents);
.title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h2 {
margin: 0;
}
}
.sectionContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.elementType {
font-size: 12px;
font-weight: 700;
margin-top: 8px;
}
.elementsCount {
width: 100%;
font-size: 12px;
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.statsItem {
margin-top: 8px;
width: 100%;
margin-bottom: 4px;
display: grid;
gap: 4px;
.label {
margin-right: 4px;
}
}
h3 {
white-space: nowrap;
margin: 0;
}
.close {
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
.divider {
width: 100%;
height: 1px;
background-color: var(--default-border-color);
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
}
}
} }
&__footer { &__footer {
+1 -33
View File
@@ -60,7 +60,6 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
@@ -85,14 +84,6 @@ interface LayerUIProps {
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean; isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@@ -149,10 +140,6 @@ const LayerUI = ({
children, children,
app, app,
isCollaborating, isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@@ -360,7 +347,7 @@ const LayerUI = ({
)} )}
{shouldShowStats && ( {shouldShowStats && (
<Stats <Stats
scene={app.scene} app={app}
onClose={() => { onClose={() => {
actionManager.executeAction(actionToggleStats); actionManager.executeAction(actionToggleStats);
}} }}
@@ -482,25 +469,6 @@ const LayerUI = ({
}} }}
/> />
)} )}
{appState.openDialog?.name === "settings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
const source =
appState.openDialog?.name === "settings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog /> <ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out /> <tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()} {renderImageExportDialog()}
@@ -1,18 +0,0 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}
@@ -1,160 +0,0 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const appState = useUIAppState();
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
if (appState.openDialog?.name !== "settings") {
return null;
}
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Wireframe to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
{/* <h2
style={{
margin: 0,
fontSize: "1.25rem",
paddingLeft: "2.5rem",
}}
>
AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited as
such we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set
your own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need
to insert it again after reload. But, you can persist locally in
your browser below.
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to
turn it into code. This dialog can be accessed using the{" "}
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</TTDDialogTab>
</TTDDialogTabs>
</Dialog>
);
};
@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
exportBackground: true, exportBackground: true,
}, },
files: null, files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -52,8 +52,8 @@
font-size: 0.75rem; font-size: 0.75rem;
line-height: 110%; line-height: 110%;
background: var(--color-success-lighter); background: var(--color-success);
color: var(--color-success); color: var(--color-success-text);
& > svg { & > svg {
width: 0.875rem; width: 0.875rem;
@@ -1,5 +1,4 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
@@ -7,7 +6,8 @@ import { useI18n } from "../i18n";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { TextField } from "./TextField"; import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton"; import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons"; import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { copyIcon } from "./icons";
import "./ShareableLinkDialog.scss"; import "./ShareableLinkDialog.scss";
@@ -24,7 +24,7 @@ export const ShareableLinkDialog = ({
setErrorMessage, setErrorMessage,
}: ShareableLinkDialogProps) => { }: ShareableLinkDialogProps) => {
const { t } = useI18n(); const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false); const [, setJustCopied] = useState(false);
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
@@ -46,7 +46,7 @@ export const ShareableLinkDialog = ({
ref.current?.select(); ref.current?.select();
}; };
const { onCopy, copyStatus } = useCopyStatus();
return ( return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small"> <Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog"> <div className="ShareableLinkDialog">
@@ -60,26 +60,16 @@ export const ShareableLinkDialog = ({
value={link} value={link}
selectOnRender selectOnRender
/> />
<Popover.Root open={justCopied}> <FilledButton
<Popover.Trigger asChild> size="large"
<FilledButton label={t("buttons.copyLink")}
size="large" icon={copyIcon}
label="Copy link" status={copyStatus}
icon={copyIcon} onClick={() => {
onClick={copyRoomLink} onCopy();
/> copyRoomLink();
</Popover.Trigger> }}
<Popover.Content />
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div> </div>
<div className="ShareableLinkDialog__description"> <div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")} 🔒 {t("alerts.uploadedSecurly")}
+3 -1
View File
@@ -6,16 +6,18 @@ const Spinner = ({
size = "1em", size = "1em",
circleWidth = 8, circleWidth = 8,
synchronized = false, synchronized = false,
className = "",
}: { }: {
size?: string | number; size?: string | number;
circleWidth?: number; circleWidth?: number;
synchronized?: boolean; synchronized?: boolean;
className?: string;
}) => { }) => {
const mountTime = React.useRef(Date.now()); const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600); const mountDelay = -(mountTime.current % 1600);
return ( return (
<div className="Spinner"> <div className={`Spinner ${className}`}>
<svg <svg
viewBox="0 0 100 100" viewBox="0 0 100 100"
style={{ style={{
@@ -0,0 +1,67 @@
import StatsDragInput from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getStepSizedValue } from "./utils";
import { getNormalizedGridStep } from "../../scene";
interface PositionProps {
property: "gridStep";
scene: Scene;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}
const STEP_SIZE = 5;
const CanvasGrid = ({
property,
scene,
appState,
setAppState,
}: PositionProps) => {
return (
<StatsDragInput
label="Grid step"
sensitivity={8}
elements={[]}
dragInputCallback={({
nextValue,
instantChange,
shouldChangeByStepSize,
setInputValue,
}) => {
setAppState((state) => {
let nextGridStep;
if (nextValue) {
nextGridStep = nextValue;
} else if (instantChange) {
nextGridStep = shouldChangeByStepSize
? getStepSizedValue(
state.gridStep + STEP_SIZE * Math.sign(instantChange),
STEP_SIZE,
)
: state.gridStep + instantChange;
}
if (!nextGridStep) {
setInputValue(state.gridStep);
return null;
}
nextGridStep = getNormalizedGridStep(nextGridStep);
setInputValue(nextGridStep);
return {
gridStep: nextGridStep,
};
});
}}
scene={scene}
value={appState.gridStep}
property={property}
appState={appState}
/>
);
};
export default CanvasGrid;
@@ -31,7 +31,11 @@ const Collapsible = ({
{label} {label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} /> <InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div> </div>
{open && <>{children}</>} {open && (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
</div>
)}
</> </>
); );
}; };
@@ -23,7 +23,6 @@ const handleDimensionChange: DragInputCallbackType<
> = ({ > = ({
accumulatedChange, accumulatedChange,
originalElements, originalElements,
originalElementsMap,
shouldKeepAspectRatio, shouldKeepAspectRatio,
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
@@ -5,7 +5,7 @@
&:focus-within { &:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest); box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-md);
} }
} }
@@ -18,17 +18,18 @@
flex-shrink: 0; flex-shrink: 0;
border: 1px solid var(--default-border-color); border: 1px solid var(--default-border-color);
border-right: 0; border-right: 0;
width: 2rem; padding: 0 0.5rem 0 0.75rem;
min-width: 1rem;
height: 2rem; height: 2rem;
box-sizing: border-box; box-sizing: border-box;
color: var(--popup-text-color); color: var(--popup-text-color);
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
border-right: 1px solid var(--default-border-color); border-right: 1px solid var(--default-border-color);
border-left: 0; border-left: 0;
} }
@@ -55,11 +56,11 @@
letter-spacing: 0.4px; letter-spacing: 0.4px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
border-left: 1px solid var(--default-border-color); border-left: 1px solid var(--default-border-color);
border-right: 0; border-right: 0;
} }
@@ -29,6 +29,7 @@ export type DragInputCallbackType<
nextValue?: number; nextValue?: number;
property: P; property: P;
originalAppState: AppState; originalAppState: AppState;
setInputValue: (value: number) => void;
}) => void; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps<
@@ -45,6 +46,8 @@ interface StatsDragInputProps<
property: T; property: T;
scene: Scene; scene: Scene;
appState: AppState; appState: AppState;
/** how many px you need to drag to get 1 unit change */
sensitivity?: number;
} }
const StatsDragInput = < const StatsDragInput = <
@@ -61,6 +64,7 @@ const StatsDragInput = <
property, property,
scene, scene,
appState, appState,
sensitivity = 1,
}: StatsDragInputProps<T, E>) => { }: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -126,27 +130,49 @@ const StatsDragInput = <
nextValue: rounded, nextValue: rounded,
property, property,
originalAppState: appState, originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
}); });
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
} }
}; };
const handleInputValueRef = useRef(handleInputValue); const callbacksRef = useRef<
handleInputValueRef.current = handleInputValue; Partial<{
handleInputValue: typeof handleInputValue;
onPointerUp: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
}>
>({});
callbacksRef.current.handleInputValue = handleInputValue;
// make sure that clicking on canvas (which umounts the component) // make sure that clicking on canvas (which umounts the component)
// updates current input value (blur isn't triggered) // updates current input value (blur isn't triggered)
useEffect(() => { useEffect(() => {
const input = inputRef.current; const input = inputRef.current;
const callbacks = callbacksRef.current;
return () => { return () => {
const nextValue = input?.value; const nextValue = input?.value;
if (nextValue) { if (nextValue) {
handleInputValueRef.current( callbacks.handleInputValue?.(
nextValue, nextValue,
stateRef.current.originalElements, stateRef.current.originalElements,
stateRef.current.originalAppState, stateRef.current.originalAppState,
); );
} }
// generally not needed, but in case `pointerup` doesn't fire and
// we don't remove the listeners that way, we should at least remove
// on unmount
window.removeEventListener(
EVENT.POINTER_MOVE,
callbacks.onPointerMove!,
false,
);
window.removeEventListener(
EVENT.POINTER_UP,
callbacks.onPointerUp!,
false,
);
}; };
}, [ }, [
// we need to track change of `editable` state as mount/unmount // we need to track change of `editable` state as mount/unmount
@@ -172,6 +198,8 @@ const StatsDragInput = <
ref={labelRef} ref={labelRef}
onPointerDown={(event) => { onPointerDown={(event) => {
if (inputRef.current && editable) { if (inputRef.current && editable) {
document.body.classList.add("excalidraw-cursor-resize");
let startValue = Number(inputRef.current.value); let startValue = Number(inputRef.current.value);
if (isNaN(startValue)) { if (isNaN(startValue)) {
startValue = 0; startValue = 0;
@@ -196,35 +224,43 @@ const StatsDragInput = <
const originalAppState: AppState = cloneJSON(appState); const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange: number | null = null; let accumulatedChange = 0;
let stepChange = 0;
document.body.classList.add("excalidraw-cursor-resize");
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if ( if (
lastPointer && lastPointer &&
originalElementsMap !== null && originalElementsMap !== null &&
originalElements !== null && originalElements !== null
accumulatedChange !== null
) { ) {
const instantChange = event.clientX - lastPointer.x; const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
dragInputCallback({ if (instantChange !== 0) {
accumulatedChange, stepChange += instantChange;
instantChange,
originalElements, if (Math.abs(stepChange) >= sensitivity) {
originalElementsMap, stepChange =
shouldKeepAspectRatio: shouldKeepAspectRatio!!, Math.sign(stepChange) *
shouldChangeByStepSize: event.shiftKey, Math.floor(Math.abs(stepChange) / sensitivity);
property,
scene, accumulatedChange += stepChange;
originalAppState,
}); dragInputCallback({
accumulatedChange,
instantChange: stepChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
});
stepChange = 0;
}
}
} }
lastPointer = { 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_MOVE, onPointerMove, false);
window.addEventListener( window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = null;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
},
false,
);
} }
}} }}
onPointerEnter={() => { 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 { getCommonBounds } from "../../element/bounds";
import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import type { AppState, ExcalidrawProps } from "../../types"; import type {
AppClassProperties,
AppState,
ExcalidrawProps,
} from "../../types";
import { CloseIcon } from "../icons"; import { CloseIcon } from "../icons";
import { Island } from "../Island"; import { Island } from "../Island";
import { throttle } from "lodash"; import { throttle } from "lodash";
import Dimension from "./Dimension"; import Dimension from "./Dimension";
import Angle from "./Angle"; import Angle from "./Angle";
import FontSize from "./FontSize"; import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension"; import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups"; import { elementsAreInSameGroup } from "../../groups";
@@ -17,14 +20,18 @@ import MultiFontSize from "./MultiFontSize";
import Position from "./Position"; import Position from "./Position";
import MultiPosition from "./MultiPosition"; import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible"; import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks"; import { isElbowArrow } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
interface StatsProps { interface StatsProps {
scene: Scene; app: AppClassProperties;
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
} }
@@ -33,11 +40,12 @@ const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => { export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const sceneNonce = props.scene.getSceneNonce() || 1; const sceneNonce = props.app.scene.getSceneNonce() || 1;
const selectedElements = props.scene.getSelectedElements({ const selectedElements = props.app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
const gridModeEnabled = isGridModeEnabled(props.app);
return ( return (
<StatsInner <StatsInner
@@ -45,23 +53,71 @@ export const Stats = (props: StatsProps) => {
appState={appState} appState={appState}
sceneNonce={sceneNonce} sceneNonce={sceneNonce}
selectedElements={selectedElements} selectedElements={selectedElements}
gridModeEnabled={gridModeEnabled}
/> />
); );
}; };
const StatsRow = ({
children,
columns = 1,
heading,
style,
...rest
}: {
children: React.ReactNode;
columns?: number;
heading?: boolean;
style?: React.CSSProperties;
} & React.HTMLAttributes<HTMLDivElement>) => (
<div
className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
...style,
}}
{...rest}
>
{children}
</div>
);
StatsRow.displayName = "StatsRow";
const StatsRows = ({
children,
order,
style,
...rest
}: {
children: React.ReactNode;
order?: number;
style?: React.CSSProperties;
} & React.HTMLAttributes<HTMLDivElement>) => (
<div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
{children}
</div>
);
StatsRows.displayName = "StatsRows";
Stats.StatsRow = StatsRow;
Stats.StatsRows = StatsRows;
export const StatsInner = memo( export const StatsInner = memo(
({ ({
scene, app,
onClose, onClose,
renderCustomStats, renderCustomStats,
selectedElements, selectedElements,
appState, appState,
sceneNonce, sceneNonce,
gridModeEnabled,
}: StatsProps & { }: StatsProps & {
sceneNonce: number; sceneNonce: number;
selectedElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
gridModeEnabled: boolean;
}) => { }) => {
const scene = app.scene;
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
@@ -106,7 +162,7 @@ export const StatsInner = memo(
}, [selectedElements, appState]); }, [selectedElements, appState]);
return ( return (
<div className="Stats"> <div className="exc-stats">
<Island padding={3}> <Island padding={3}>
<div className="title"> <div className="title">
<h2>{t("stats.title")}</h2> <h2>{t("stats.title")}</h2>
@@ -121,7 +177,6 @@ export const StatsInner = memo(
openTrigger={() => openTrigger={() =>
setAppState((state) => { setAppState((state) => {
return { return {
...state,
stats: { stats: {
open: true, open: true,
panels: state.stats.panels ^ STATS_PANELS.generalStats, panels: state.stats.panels ^ STATS_PANELS.generalStats,
@@ -130,26 +185,36 @@ export const StatsInner = memo(
}) })
} }
> >
<table> <StatsRows>
<tbody> <StatsRow heading>{t("stats.scene")}</StatsRow>
<tr> <StatsRow columns={2}>
<th colSpan={2}>{t("stats.scene")}</th> <div>{t("stats.shapes")}</div>
</tr> <div>{elements.length}</div>
<tr> </StatsRow>
<td>{t("stats.elements")}</td> <StatsRow columns={2}>
<td>{elements.length}</td> <div>{t("stats.width")}</div>
</tr> <div>{sceneDimension.width}</div>
<tr> </StatsRow>
<td>{t("stats.width")}</td> <StatsRow columns={2}>
<td>{sceneDimension.width}</td> <div>{t("stats.height")}</div>
</tr> <div>{sceneDimension.height}</div>
<tr> </StatsRow>
<td>{t("stats.height")}</td> {gridModeEnabled && (
<td>{sceneDimension.height}</td> <>
</tr> <StatsRow heading>Canvas</StatsRow>
{renderCustomStats?.(elements, appState)} <StatsRow>
</tbody> <CanvasGrid
</table> property="gridStep"
scene={scene}
appState={appState}
setAppState={setAppState}
/>
</StatsRow>
</>
)}
</StatsRows>
{renderCustomStats?.(elements, appState)}
</Collapsible> </Collapsible>
{selectedElements.length > 0 && ( {selectedElements.length > 0 && (
@@ -167,7 +232,6 @@ export const StatsInner = memo(
openTrigger={() => openTrigger={() =>
setAppState((state) => { setAppState((state) => {
return { return {
...state,
stats: { stats: {
open: true, open: true,
panels: panels:
@@ -177,117 +241,139 @@ export const StatsInner = memo(
}) })
} }
> >
{singleElement && ( <StatsRows>
<div className="sectionContent"> {singleElement && (
<div className="elementType"> <>
{t(`element.${singleElement.type}`)} <StatsRow heading data-testid="stats-element-type">
</div> {t(`element.${singleElement.type}`)}
</StatsRow>
<div className="statsItem"> <StatsRow>
<Position <Position
element={singleElement} element={singleElement}
property="x" property="x"
elementsMap={elementsMap} elementsMap={elementsMap}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
<Position </StatsRow>
element={singleElement} <StatsRow>
property="y" <Position
elementsMap={elementsMap} element={singleElement}
scene={scene} property="y"
appState={appState} elementsMap={elementsMap}
/> scene={scene}
<Dimension appState={appState}
property="width" />
element={singleElement} </StatsRow>
scene={scene} <StatsRow>
appState={appState} <Dimension
/> property="width"
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
/>
{!isElbowArrow(singleElement) && (
<Angle
property="angle"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} 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 <StatsRow>
property="fontSize" <FontSize
element={singleElement} property="fontSize"
scene={scene} element={singleElement}
appState={appState} scene={scene}
/> appState={appState}
</div> />
</div> </StatsRow>
)} </>
)}
{multipleElements && ( {multipleElements && (
<div className="sectionContent"> <>
{elementsAreInSameGroup(multipleElements) && ( {elementsAreInSameGroup(multipleElements) && (
<div className="elementType">{t("element.group")}</div> <StatsRow heading>{t("element.group")}</StatsRow>
)} )}
<div className="elementsCount"> <StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
<div>{t("stats.elements")}</div> <div>{t("stats.shapes")}</div>
<div>{selectedElements.length}</div> <div>{selectedElements.length}</div>
</div> </StatsRow>
<div className="statsItem"> <StatsRow>
<MultiPosition <MultiPosition
property="x" property="x"
elements={multipleElements} elements={multipleElements}
elementsMap={elementsMap} elementsMap={elementsMap}
atomicUnits={atomicUnits} atomicUnits={atomicUnits}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
<MultiPosition </StatsRow>
property="y" <StatsRow>
elements={multipleElements} <MultiPosition
elementsMap={elementsMap} property="y"
atomicUnits={atomicUnits} elements={multipleElements}
scene={scene} elementsMap={elementsMap}
appState={appState} atomicUnits={atomicUnits}
/> scene={scene}
<MultiDimension appState={appState}
property="width" />
elements={multipleElements} </StatsRow>
elementsMap={elementsMap} <StatsRow>
atomicUnits={atomicUnits} <MultiDimension
scene={scene} property="width"
appState={appState} elements={multipleElements}
/> elementsMap={elementsMap}
<MultiDimension atomicUnits={atomicUnits}
property="height" scene={scene}
elements={multipleElements} appState={appState}
elementsMap={elementsMap} />
atomicUnits={atomicUnits} </StatsRow>
scene={scene} <StatsRow>
appState={appState} <MultiDimension
/> property="height"
<MultiAngle elements={multipleElements}
property="angle" elementsMap={elementsMap}
elements={multipleElements} atomicUnits={atomicUnits}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
<MultiFontSize </StatsRow>
property="fontSize" <StatsRow>
elements={multipleElements} <MultiAngle
scene={scene} property="angle"
appState={appState} elements={multipleElements}
elementsMap={elementsMap} scene={scene}
/> appState={appState}
</div> />
</div> </StatsRow>
)} <StatsRow>
<MultiFontSize
property="fontSize"
elements={multipleElements}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
</StatsRow>
</>
)}
</StatsRows>
</Collapsible> </Collapsible>
</div> </div>
)} )}
@@ -299,7 +385,9 @@ export const StatsInner = memo(
return ( return (
prev.sceneNonce === next.sceneNonce && prev.sceneNonce === next.sceneNonce &&
prev.selectedElements === next.selectedElements && prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
); );
}, },
); );
@@ -32,21 +32,6 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null; let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null; let elementStats: HTMLElement | null | undefined = null;
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
const testInputProperty = ( const testInputProperty = (
element: ExcalidrawElement, element: ExcalidrawElement,
property: "x" | "y" | "width" | "height" | "angle" | "fontSize", property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
@@ -54,7 +39,7 @@ const testInputProperty = (
initialValue: number, initialValue: number,
nextValue: number, nextValue: number,
) => { ) => {
const input = getStatsProperty(label)?.querySelector( const input = UI.queryStatsProperty(label)?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -136,7 +121,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small position change", async () => { it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -148,7 +133,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => { it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -159,7 +144,7 @@ describe("binding with linear elements", () => {
it("should unbind linear element on large position change", async () => { it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -171,7 +156,7 @@ describe("binding with linear elements", () => {
it("should remain bound to linear element on small angle change", async () => { it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement; const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -225,18 +210,14 @@ describe("stats for a generic element", () => {
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties")); expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
// element type // element type
const elementType = elementStats?.querySelector(".elementType"); const elementType = queryByTestId(elementStats!, "stats-element-type");
expect(elementType).toBeDefined(); expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle")); expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties // properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => { ["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect( expect(
properties?.querySelector?.( stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
`.drag-input-container[data-testid="${label}"]`,
),
).toBeDefined(); ).toBeDefined();
}); });
}); });
@@ -257,7 +238,7 @@ describe("stats for a generic element", () => {
const rectangle = h.elements[0]; const rectangle = h.elements[0];
const rectangleId = rectangle.id; const rectangleId = rectangle.id;
const input = getStatsProperty("W")?.querySelector( const input = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -287,11 +268,11 @@ describe("stats for a generic element", () => {
rectangle.angle, rectangle.angle,
); );
const xInput = getStatsProperty("X")?.querySelector( const xInput = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
const yInput = getStatsProperty("Y")?.querySelector( const yInput = UI.queryStatsProperty("Y")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -417,7 +398,7 @@ describe("stats for a non-generic element", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
// can change font size // can change font size
const input = getStatsProperty("F")?.querySelector( const input = UI.queryStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -426,9 +407,9 @@ describe("stats for a non-generic element", () => {
expect(text.fontSize).toBe(36); expect(text.fontSize).toBe(36);
// cannot change width or height // cannot change width or height
const width = getStatsProperty("W")?.querySelector(".drag-input"); const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined(); expect(width).toBeUndefined();
const height = getStatsProperty("H")?.querySelector(".drag-input"); const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined(); expect(height).toBeUndefined();
// min font size is 4 // min font size is 4
@@ -456,7 +437,7 @@ describe("stats for a non-generic element", () => {
expect(elementStats).toBeDefined(); expect(elementStats).toBeDefined();
// cannot change angle // cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input"); const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined(); expect(angle).toBeUndefined();
// can change width or height // can change width or height
@@ -506,7 +487,7 @@ describe("stats for a non-generic element", () => {
API.setElements([container, text]); API.setElements([container, text]);
API.setSelectedElements([container]); API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector( const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
@@ -570,15 +551,15 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector( const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width?.value).toBe("Mixed"); expect(width?.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector( const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height?.value).toBe("Mixed"); expect(height?.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector( const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
@@ -629,25 +610,25 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const width = getStatsProperty("W")?.querySelector( const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width).toBeDefined(); expect(width).toBeDefined();
expect(width.value).toBe("Mixed"); expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector( const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).toBeDefined(); expect(height).toBeDefined();
expect(height.value).toBe("Mixed"); expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector( const angle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle).toBeDefined(); expect(angle).toBeDefined();
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector( const fontSize = UI.queryStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
@@ -692,7 +673,7 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const x = getStatsProperty("X")?.querySelector( const x = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -705,7 +686,7 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].x).toBe(400); expect(h.elements[1].x).toBe(400);
expect(x.value).toBe("300"); expect(x.value).toBe("300");
const y = getStatsProperty("Y")?.querySelector( const y = UI.queryStatsProperty("Y")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -718,13 +699,13 @@ describe("stats for multiple elements", () => {
expect(h.elements[1].y).toBe(300); expect(h.elements[1].y).toBe(300);
expect(y.value).toBe("200"); expect(y.value).toBe("200");
const width = getStatsProperty("W")?.querySelector( const width = UI.queryStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width).toBeDefined(); expect(width).toBeDefined();
expect(Number(width.value)).toBe(200); expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector( const height = UI.queryStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).toBeDefined(); expect(height).toBeDefined();
@@ -41,7 +41,8 @@ export type StatsInputProperty =
| "width" | "width"
| "height" | "height"
| "angle" | "angle"
| "fontSize"; | "fontSize"
| "gridStep";
export const SMALLEST_DELTA = 0.01; export const SMALLEST_DELTA = 0.01;
@@ -7,10 +7,7 @@ import { isMemberOf } from "../../utils";
const TTDDialogTabs = ( const TTDDialogTabs = (
props: { props: {
children: ReactNode; children: ReactNode;
} & ( } & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
),
) => { ) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
@@ -39,13 +36,6 @@ const TTDDialogTabs = (
} }
} }
if ( if (
props.dialog === "settings" &&
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab, source: "settings" },
});
} else if (
props.dialog === "ttd" && props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid"], tab) isMemberOf(["text-to-diagram", "mermaid"], tab)
) { ) {
@@ -202,7 +202,7 @@ const getRelevantAppStateProps = (
activeEmbeddable: appState.activeEmbeddable, activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingElement: appState.editingElement, editingTextElement: appState.editingTextElement,
}); });
const areEqual = ( const areEqual = (
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import type { NonDeletedSceneElementsMap } from "../../element/types";
import type { AppState } from "../../types";
import type {
RenderableElementsMap,
StaticCanvasRenderConfig,
} from "../../scene/types";
import type { RoughCanvas } from "roughjs/bin/canvas";
import { renderNewElementScene } from "../../renderer/renderNewElementScene";
import { isRenderThrottlingEnabled } from "../../reactUtils";
interface NewElementCanvasProps {
appState: AppState;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
rc: RoughCanvas;
renderConfig: StaticCanvasRenderConfig;
}
const NewElementCanvas = (props: NewElementCanvasProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!canvasRef.current) {
return;
}
renderNewElementScene(
{
canvas: canvasRef.current,
scale: props.scale,
newElement: props.appState.newElement,
elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap,
rc: props.rc,
renderConfig: props.renderConfig,
appState: props.appState,
},
isRenderThrottlingEnabled(),
);
});
return (
<canvas
className="excalidraw__canvas"
style={{
width: props.appState.width,
height: props.appState.height,
}}
width={props.appState.width * props.scale}
height={props.appState.height * props.scale}
ref={canvasRef}
/>
);
};
export default NewElementCanvas;
@@ -101,6 +101,7 @@ const getRelevantAppStateProps = (
exportScale: appState.exportScale, exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize, gridSize: appState.gridSize,
gridStep: appState.gridStep,
frameRendering: appState.frameRendering, frameRendering: appState.frameRendering,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
@@ -13,6 +13,7 @@ const DropdownMenuItemLink = ({
onSelect, onSelect,
className = "", className = "",
selected, selected,
rel = "noreferrer",
...rest ...rest
}: { }: {
href: string; href: string;
@@ -22,6 +23,7 @@ const DropdownMenuItemLink = ({
className?: string; className?: string;
selected?: boolean; selected?: boolean;
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
rel?: string;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => { } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
+4 -2
View File
@@ -112,6 +112,7 @@ export const ENV = {
export const CLASSES = { export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
}; };
/** /**
@@ -179,7 +180,8 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const DEFAULT_GRID_SIZE = 20;
export const DEFAULT_GRID_STEP = 5;
export const IMAGE_MIME_TYPES = { export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml", svg: "image/svg+xml",
@@ -234,7 +236,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1; export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0; export const MAX_ZOOM = 30;
export const HYPERLINK_TOOLTIP_DELAY = 300; export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
+1 -1
View File
@@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left { .App-menu__left {
overflow-y: auto; overflow-y: auto;
padding: 0.75rem; padding: 0.75rem;
width: 200px; width: 12.5rem;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
+8 -2
View File
@@ -129,8 +129,14 @@
--color-muted-background-darker: var(--color-gray-100); --color-muted-background-darker: var(--color-gray-100);
--color-promo: var(--color-primary); --color-promo: var(--color-primary);
--color-success: #268029;
--color-success-lighter: #cafccc; --color-success: #cafccc;
--color-success-darker: #bafabc;
--color-success-darkest: #a5eba8;
--color-success-text: #268029;
--color-success-contrast: #65bb6a;
--color-success-contrast-hover: #6bcf70;
--color-success-contrast-active: #6edf74;
--color-logo-icon: var(--color-primary); --color-logo-icon: var(--color-primary);
--color-logo-text: #190064; --color-logo-text: #190064;
-105
View File
@@ -1,105 +0,0 @@
import { THEME } from "../constants";
import type { Theme } from "../element/types";
import type { DataURL } from "../types";
import type { OpenAIInput, OpenAIOutput } from "./ai/types";
export type MagicCacheData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
Your role is to transform low-fidelity wireframes into working front-end HTML code.
YOU MUST FOLLOW FOLLOWING RULES:
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
- Inline JavaScript when needed
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
- Source images from Unsplash or create applicable placeholders
- Interpret annotations as intended vs literal UI
- Fill gaps using your expertise in UX and business logic
- generate primarily for desktop UI, but make it responsive.
- Use grid and flexbox wherever applicable.
- Convert the wireframe in its entirety, don't omit elements if possible.
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
Your goal is a production-ready prototype that brings the wireframes to life.
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
export async function diagramToHTML({
image,
apiKey,
text,
theme = THEME.LIGHT,
}: {
image: DataURL;
apiKey: string;
text: string;
theme?: Theme;
}) {
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
model: "gpt-4-vision-preview",
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
max_tokens: 4096,
temperature: 0.1,
messages: [
{
role: "system",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: image,
detail: "high",
},
},
{
type: "text",
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
},
{
type: "text",
text,
},
],
},
],
};
let result:
| ({ ok: true } & OpenAIOutput.ChatCompletion)
| ({ ok: false } & OpenAIOutput.APIError);
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (resp.ok) {
const json: OpenAIOutput.ChatCompletion = await resp.json();
result = { ...json, ok: true };
} else {
const json: OpenAIOutput.APIError = await resp.json();
result = { ...json, ok: false };
}
return result;
}
+1 -1
View File
@@ -24,7 +24,7 @@ const shouldDiscardRemoteElement = (
if ( if (
local && local &&
// local element is being edited // local element is being edited
(local.id === localAppState.editingElement?.id || (local.id === localAppState.editingTextElement?.id ||
local.id === localAppState.resizingElement?.id || local.id === localAppState.resizingElement?.id ||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
// local element is newer // local element is newer
+28 -16
View File
@@ -10,12 +10,7 @@ import type {
PointBinding, PointBinding,
StrokeRoundness, StrokeRoundness,
} from "../element/types"; } from "../element/types";
import type { import type { AppState, BinaryFiles, LibraryItem } from "../types";
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import type { ImportedDataState, LegacyAppState } from "./types"; import type { ImportedDataState, LegacyAppState } from "./types";
import { import {
getNonDeletedElements, getNonDeletedElements,
@@ -39,11 +34,17 @@ import {
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_GRID_SIZE,
DEFAULT_GRID_STEP,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import {
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement"; import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -51,6 +52,12 @@ import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { normalizeFixedPoint } from "../element/binding";
import {
getNormalizedGridSize,
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -106,7 +113,7 @@ const repairBinding = (
...binding, ...binding,
focus: binding.focus || 0, focus: binding.focus || 0,
fixedPoint: isElbowArrow(element) fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number]) ? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
: null, : null,
}; };
}; };
@@ -613,19 +620,24 @@ export const restoreAppState = (
locked: nextAppState.activeTool.locked ?? false, locked: nextAppState.activeTool.locked ?? false,
}, },
// Migrates from previous version where appState.zoom was a number // Migrates from previous version where appState.zoom was a number
zoom: zoom: {
typeof appState.zoom === "number" value: getNormalizedZoom(
? { isFiniteNumber(appState.zoom)
value: appState.zoom as NormalizedZoomValue, ? appState.zoom
} : appState.zoom?.value ?? defaultAppState.zoom.value,
: appState.zoom?.value ),
? appState.zoom },
: defaultAppState.zoom,
openSidebar: openSidebar:
// string (legacy) // string (legacy)
typeof (appState.openSidebar as any as string) === "string" typeof (appState.openSidebar as any as string) === "string"
? { name: DEFAULT_SIDEBAR.name } ? { name: DEFAULT_SIDEBAR.name }
: nextAppState.openSidebar, : nextAppState.openSidebar,
gridSize: getNormalizedGridSize(
isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
),
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
}; };
}; };
+30 -13
View File
@@ -41,6 +41,7 @@ import {
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -71,6 +72,7 @@ import {
vectorToHeading, vectorToHeading,
type Heading, type Heading,
} from "./heading"; } from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@@ -751,7 +753,8 @@ export const bindPointToSnapToElementOutline = (
const aabb = bindableElement && aabbForElement(bindableElement); const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) { if (bindableElement && aabb) {
// TODO: Dirty hack until tangents are properly calculated // TODO: Dirty hacks until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point);
const intersections = [ const intersections = [
...intersectElementWithLine( ...intersectElementWithLine(
bindableElement, bindableElement,
@@ -767,18 +770,14 @@ export const bindPointToSnapToElementOutline = (
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, 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 = const isVertical =
compareHeading(heading, HEADING_LEFT) || compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT); compareHeading(heading, HEADING_RIGHT);
const dist = distanceToBindableElement(bindableElement, point, elementsMap); const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap),
);
const isInner = isVertical const isInner = isVertical
? dist < bindableElement.width * -0.1 ? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1; : dist < bindableElement.height * -0.1;
@@ -1000,7 +999,7 @@ const updateBoundPoint = (
if (isElbowArrow(linearElement)) { if (isElbowArrow(linearElement)) {
const fixedPoint = const fixedPoint =
binding.fixedPoint ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( calculateFixedPointForElbowArrowBinding(
linearElement, linearElement,
bindableElement, bindableElement,
@@ -1113,12 +1112,12 @@ export const calculateFixedPointForElbowArrowBinding = (
) as Point; ) as Point;
return { return {
fixedPoint: [ fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width, hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height, hoveredElement.height,
] as [number, number], ]),
}; };
}; };
@@ -1605,6 +1604,10 @@ const intersectElementWithLine = (
gap: number = 0, gap: number = 0,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point[] => { ): Point[] => {
if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap);
}
const relateToCenter = relativizationToElementCenter(element, elementsMap); const relateToCenter = relativizationToElementCenter(element, elementsMap);
const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
@@ -2171,7 +2174,8 @@ export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
) => { ) => {
const [fixedX, fixedY] = fixedPointRatio; const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return rotatePoint( return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY], [element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element), getCenterForElement(element),
@@ -2225,3 +2229,16 @@ export const getArrowLocalFixedPoints = (
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap), 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 = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
@@ -748,10 +749,10 @@ export const getCommonBounds = (
let minY = Infinity; let minY = Infinity;
let maxY = -Infinity; let maxY = -Infinity;
const elementsMap = arrayToMap(elements); const _elementsMap = elementsMap || arrayToMap(elements);
elements.forEach((element) => { elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap);
minX = Math.min(minX, x1); minX = Math.min(minX, x1);
minY = Math.min(minY, y1); minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2); maxX = Math.max(maxX, x2);
+51 -26
View File
@@ -4,7 +4,12 @@ import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types"; import type { NonDeletedExcalidrawElement } from "./types";
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types"; import type {
AppState,
NormalizedZoomValue,
NullableGridSize,
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math"; import { getGridPoint } from "../math";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
@@ -26,7 +31,7 @@ export const dragSelectedElements = (
x: number; x: number;
y: number; y: number;
}, },
gridSize: AppState["gridSize"], gridSize: NullableGridSize,
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
@@ -101,7 +106,7 @@ const calculateOffset = (
commonBounds: Bounds, commonBounds: Bounds,
dragOffset: { x: number; y: number }, dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number }, snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"], gridSize: NullableGridSize,
): { x: number; y: number } => { ): { x: number; y: number } => {
const [x, y] = commonBounds; const [x, y] = commonBounds;
let nextX = x + dragOffset.x + snapOffset.x; let nextX = x + dragOffset.x + snapOffset.x;
@@ -154,26 +159,42 @@ export const getDragOffsetXY = (
return [x - x1, y - y1]; return [x - x1, y - y1];
}; };
export const dragNewElement = ( export const dragNewElement = ({
newElement: NonDeletedExcalidrawElement, newElement,
elementType: AppState["activeTool"]["type"], elementType,
originX: number, originX,
originY: number, originY,
x: number, x,
y: number, y,
width: number, width,
height: number, height,
shouldMaintainAspectRatio: boolean, shouldMaintainAspectRatio,
shouldResizeFromCenter: boolean, shouldResizeFromCenter,
zoom: NormalizedZoomValue, zoom,
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 /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */ true */
widthAspectRatio?: number | null, widthAspectRatio?: number | null;
originOffset: { originOffset?: {
x: number; x: number;
y: number; y: number;
} | null = null, } | null;
) => { informMutation?: boolean;
}) => {
if (shouldMaintainAspectRatio && newElement.type !== "selection") { if (shouldMaintainAspectRatio && newElement.type !== "selection") {
if (widthAspectRatio) { if (widthAspectRatio) {
height = width / widthAspectRatio; height = width / widthAspectRatio;
@@ -237,12 +258,16 @@ export const dragNewElement = (
} }
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
mutateElement(newElement, { mutateElement(
x: newX + (originOffset?.x ?? 0), newElement,
y: newY + (originOffset?.y ?? 0), {
width, x: newX + (originOffset?.x ?? 0),
height, y: newY + (originOffset?.y ?? 0),
...textAutoResize, 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, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks"; export { isTextElement, isExcalidrawElement } from "./typeChecks";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
export { export {
getPerfectElementSize, getPerfectElementSize,
getLockedLinearCursorAlignSize, getLockedLinearCursorAlignSize,
@@ -36,6 +36,8 @@ import type {
AppState, AppState,
PointerCoords, PointerCoords,
InteractiveCanvasAppState, InteractiveCanvasAppState,
AppClassProperties,
NullableGridSize,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -44,7 +46,7 @@ import {
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { tupleToCoors } from "../utils"; import { toBrandedType, tupleToCoors } from "../utils";
import { import {
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
@@ -209,7 +211,7 @@ export class LinearElementEditor {
/** @returns whether point was dragged */ /** @returns whether point was dragged */
static handlePointDragging( static handlePointDragging(
event: PointerEvent, event: PointerEvent,
appState: AppState, app: AppClassProperties,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: ( maybeSuggestBinding: (
@@ -279,7 +281,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
referencePoint, referencePoint,
[scenePointerX, scenePointerY], [scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -299,7 +301,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
@@ -315,7 +317,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
) )
: ([ : ([
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
@@ -695,7 +697,7 @@ export class LinearElementEditor {
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
appState: AppState, app: AppClassProperties,
store: Store, store: Store,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
@@ -705,6 +707,7 @@ export class LinearElementEditor {
hitElement: NonDeleted<ExcalidrawElement> | null; hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null; linearElementEditor: LinearElementEditor | null;
} { } {
const appState = app.state;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
@@ -741,7 +744,7 @@ export class LinearElementEditor {
} }
if (event.altKey && appState.editingLinearElement) { if (event.altKey && appState.editingLinearElement) {
if ( if (
linearElementEditor.lastUncommittedPoint == null || linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element) !isElbowArrow(element)
) { ) {
mutateElement(element, { mutateElement(element, {
@@ -752,7 +755,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
), ),
], ],
}); });
@@ -876,9 +879,10 @@ export class LinearElementEditor {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
appState: AppState, app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null { ): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return null; return null;
} }
@@ -915,7 +919,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
lastCommittedPoint, lastCommittedPoint,
[scenePointerX, scenePointerY], [scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
newPoint = [ newPoint = [
@@ -930,7 +934,7 @@ export class LinearElementEditor {
scenePointerY - appState.editingLinearElement.pointerOffset.y, scenePointerY - appState.editingLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null ? null
: appState.gridSize, : app.getEffectiveGridSize(),
); );
} }
@@ -1065,7 +1069,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap, elementsMap: ElementsMap,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
gridSize: number | null, gridSize: NullableGridSize,
): Point { ): Point {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -1363,7 +1367,7 @@ export class LinearElementEditor {
static addMidpoint( static addMidpoint(
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords, pointerCoords: PointerCoords,
appState: AppState, app: AppClassProperties,
snapToGrid: boolean, snapToGrid: boolean,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ) {
@@ -1388,7 +1392,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
snapToGrid && !isElbowArrow(element) ? appState.gridSize : null, snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
); );
const points = [ const points = [
...element.points.slice(0, segmentMidpoint.index!), ...element.points.slice(0, segmentMidpoint.index!),
@@ -1447,9 +1451,15 @@ export class LinearElementEditor {
: null; : null;
} }
const mergedElementsMap = options?.changedElements
? toBrandedType<SceneElementsMap>(
new Map([...elementsMap, ...options.changedElements]),
)
: elementsMap;
mutateElbowArrow( mutateElbowArrow(
element, element,
elementsMap, mergedElementsMap,
nextPoints, nextPoints,
[offsetX, offsetY], [offsetX, offsetY],
bindings, bindings,
@@ -1479,7 +1489,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap, elementsMap: ElementsMap,
referencePoint: Point, referencePoint: Point,
scenePointer: Point, scenePointer: Point,
gridSize: number | null, gridSize: NullableGridSize,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
+2 -1
View File
@@ -375,12 +375,13 @@ export const newFreeDrawElement = (
type: "freedraw"; type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"]; points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean; simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
return { return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
pressures: [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null, lastCommittedPoint: null,
}; };
+1 -16
View File
@@ -22,21 +22,6 @@ const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
describe("elbow arrow routing", () => { describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => { it("can properly generate orthogonal arrow points", () => {
const scene = new Scene(); const scene = new Scene();
@@ -193,7 +178,7 @@ describe("elbow arrow ui", () => {
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
UI.updateInput(inputAngle, String("40")); UI.updateInput(inputAngle, String("40"));
@@ -9,7 +9,7 @@ export const showSelectedShapeActions = (
Boolean( Boolean(
!appState.viewModeEnabled && !appState.viewModeEnabled &&
((appState.activeTool.type !== "custom" && ((appState.activeTool.type !== "custom" &&
(appState.editingElement || (appState.editingTextElement ||
(appState.activeTool.type !== "selection" && (appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" && appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" && appState.activeTool.type !== "hand" &&
+44 -1
View File
@@ -3,7 +3,7 @@ import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types"; import type { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted // TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
@@ -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 * 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; return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
}; };
/** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = (
elements: readonly ExcalidrawElement[],
separator = "\n\n",
) => {
const text = elements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join(separator);
return text;
};
@@ -61,9 +61,9 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect( expect(
(h.state.editingElement as ExcalidrawTextElement).containerId, (h.state.editingTextElement as ExcalidrawTextElement).containerId,
).toBe(null); ).toBe(null);
}); });
@@ -105,7 +105,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(boundText2.id); expect(h.state.editingTextElement?.id).toBe(boundText2.id);
}); });
it("should not create bound text on ENTER if text exists at container center", () => { it("should not create bound text on ENTER if text exists at container center", () => {
@@ -133,7 +133,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
}); });
it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => { it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
@@ -174,7 +174,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).toBe(boundText.id); expect(h.state.editingTextElement?.id).toBe(boundText.id);
}); });
it("should edit text under cursor when clicked with text tool", async () => { it("should edit text under cursor when clicked with text tool", async () => {
@@ -195,7 +195,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, false); const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
@@ -217,7 +217,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, false); const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
@@ -286,7 +286,7 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
const editor = await getTextEditor(textEditorSelector); const editor = await getTextEditor(textEditorSelector);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingTextElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
const nextText = `${wrappedText} is great!`; const nextText = `${wrappedText} is great!`;
@@ -881,7 +881,7 @@ describe("textWysiwyg", () => {
expect(await getTextEditor(textEditorSelector, false)).toBe(null); expect(await getTextEditor(textEditorSelector, false)).toBe(null);
expect(h.state.editingElement).toBe(null); expect(h.state.editingTextElement).toBe(null);
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
+49 -15
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, isSafari } from "../constants"; import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -38,7 +38,11 @@ import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
} from "../actions/actionProperties"; } from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import {
actionResetZoom,
actionZoomIn,
actionZoomOut,
} from "../actions/actionCanvas";
import type App from "../components/App"; import type App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
@@ -357,7 +361,16 @@ export const textWysiwyg = ({
}; };
editable.oninput = () => { editable.oninput = () => {
onChange(normalizeText(editable.value)); const normalized = normalizeText(editable.value);
if (editable.value !== normalized) {
const selectionStart = editable.selectionStart;
editable.value = normalized;
// put the cursor at some position close to where it was before
// normalization (otherwise it'll end up at the end of the text)
editable.selectionStart = selectionStart;
editable.selectionEnd = selectionStart;
}
onChange(editable.value);
}; };
} }
@@ -370,6 +383,10 @@ export const textWysiwyg = ({
event.preventDefault(); event.preventDefault();
app.actionManager.executeAction(actionZoomOut); app.actionManager.executeAction(actionZoomOut);
updateWysiwygStyle(); updateWysiwygStyle();
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionResetZoom);
updateWysiwygStyle();
} else if (actionDecreaseFontSize.keyTest(event)) { } else if (actionDecreaseFontSize.keyTest(event)) {
app.actionManager.executeAction(actionDecreaseFontSize); app.actionManager.executeAction(actionDecreaseFontSize);
} else if (actionIncreaseFontSize.keyTest(event)) { } else if (actionIncreaseFontSize.keyTest(event)) {
@@ -584,6 +601,7 @@ export const textWysiwyg = ({
window.removeEventListener("blur", handleSubmit); window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit); window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate(); unbindUpdate();
unbindOnScroll();
editable.remove(); editable.remove();
}; };
@@ -610,10 +628,29 @@ export const textWysiwyg = ({
}); });
}; };
const temporarilyDisableSubmit = () => {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
};
// prevent blur when changing properties from the menu // prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => { const onPointerDown = (event: MouseEvent) => {
const target = event?.target; const target = event?.target;
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
if (target instanceof HTMLTextAreaElement) {
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
temporarilyDisableSubmit();
return;
}
const isPropertiesTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("properties-trigger"); target.classList.contains("properties-trigger");
@@ -621,17 +658,14 @@ export const textWysiwyg = ({
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) && event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isPropertiesTrigger isPropertiesTrigger
) { ) {
editable.onblur = null; temporarilyDisableSubmit();
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener("blur", handleSubmit);
} else if ( } else if (
event.target instanceof HTMLElement &&
event.target instanceof HTMLCanvasElement && event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF // Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do // so without introducing crazier hacks, nothing we can do
@@ -650,7 +684,7 @@ export const textWysiwyg = ({
}; };
// handle updates of textElement properties of editing element // handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle(); updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest( const isPopupOpened = !!document.activeElement?.closest(
".properties-content", ".properties-content",
@@ -660,6 +694,10 @@ export const textWysiwyg = ({
} }
}); });
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
updateWysiwygStyle();
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let isDestroyed = false; let isDestroyed = false;
@@ -690,10 +728,6 @@ export const textWysiwyg = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.addEventListener("pointerdown", onPointerDown, { capture: true }); window.addEventListener("pointerdown", onPointerDown, { capture: true });
}); });
window.addEventListener("wheel", stopEvent, {
passive: false,
capture: true,
});
window.addEventListener("beforeunload", handleSubmit); window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")! ?.querySelector(".excalidraw-textEditorContainer")!
+51 -1
View File
@@ -1,7 +1,9 @@
import type { LineSegment } from "../../utils";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import type { ElementOrToolType } from "../types"; import type { ElementOrToolType, Point } from "../types";
import type { MarkNonNullable } from "../utility-types"; import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils"; import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -24,6 +26,7 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@@ -175,6 +178,23 @@ export const isRectanguloidElement = (
); );
}; };
// TODO: Remove this when proper distance calculation is introduced
// @see binding.ts:distanceToBindableElement()
export const isRectangularElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "text" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe")
);
};
export const isTextBindableContainer = ( export const isTextBindableContainer = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
@@ -219,6 +239,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 = ( export const hasBoundTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => { ): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
@@ -294,3 +324,23 @@ export const isFixedPointBinding = (
): binding is FixedPointBinding => { ): binding is FixedPointBinding => {
return binding.fixedPoint != null; return binding.fixedPoint != null;
}; };
// TODO: Move this to @excalidraw/math
export const isPoint = (point: unknown): point is Point =>
Array.isArray(point) && point.length === 2;
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
// TODO: Move this to @excalidraw/math
export const isLineSegment = (segment: unknown): segment is LineSegment =>
Array.isArray(segment) &&
segment.length === 2 &&
isPoint(segment[0]) &&
isPoint(segment[0]);
+17 -2
View File
@@ -12,7 +12,6 @@ import type {
Merge, Merge,
ValueOf, ValueOf,
} from "../utility-types"; } from "../utility-types";
import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@@ -101,11 +100,22 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
type: "embeddable"; type: "embeddable";
}>; }>;
export type MagicGenerationData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
export type ExcalidrawIframeElement = _ExcalidrawElementBase & export type ExcalidrawIframeElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "iframe"; type: "iframe";
// TODO move later to AI-specific frame // TODO move later to AI-specific frame
customData?: { generationData?: MagicCacheData }; customData?: { generationData?: MagicGenerationData };
}>; }>;
export type ExcalidrawIframeLikeElement = export type ExcalidrawIframeLikeElement =
@@ -160,6 +170,11 @@ export type ExcalidrawGenericElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement; | ExcalidrawEllipseElement;
export type ExcalidrawFlowchartNodeElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable * no computed data. The list of all ExcalidrawElements should be shareable
+71 -17
View File
@@ -1,12 +1,14 @@
import { stringToBase64, toByteString } from "../data/encode"; import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata"; import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader";
export interface Font { export interface Font {
urls: URL[]; urls: URL[];
fontFace: FontFace; fontFace: FontFace;
getContent(): Promise<string>; getContent(codePoints: ReadonlySet<number>): Promise<string>;
} }
export const UNPKG_PROD_URL = `https://unpkg.com/${ export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build ? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app) : "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
@@ -32,21 +34,32 @@ export class ExcalidrawFont implements Font {
} }
/** /**
* Tries to fetch woff2 content, based on the registered urls. * Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
* Returns last defined url in case of errors.
* *
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment. * NOTE: assumes usage of `dataurl` outside the browser environment
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/ */
public async getContent(): Promise<string> { public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0; let i = 0;
const errorMessages = []; const errorMessages = [];
while (i < this.urls.length) { while (i < this.urls.length) {
const url = this.urls[i]; const url = this.urls[i];
// it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") { if (url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch const arrayBuffer = Buffer.from(
return url.toString(); url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
} }
try { try {
@@ -57,13 +70,13 @@ export class ExcalidrawFont implements Font {
}); });
if (response.ok) { if (response.ok) {
const mimeType = await response.headers.get("Content-Type"); const arrayBuffer = await response.arrayBuffer();
const buffer = await response.arrayBuffer(); const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return `data:${mimeType};base64,${await stringToBase64( return base64;
await toByteString(buffer),
true,
)}`;
} }
// response not ok, try to continue // response not ok, try to continue
@@ -89,6 +102,48 @@ export class ExcalidrawFont implements Font {
return this.urls.length ? this.urls[this.urls.length - 1].toString() : ""; return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
} }
/**
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
*
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs
*
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
try {
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
const { compress, decompress } = await loadWoff2();
const { subset } = await loadHbSubset();
const decompressedBinary = decompress(arrayBuffer).buffer;
const subsetSnft = subset(decompressedBinary, codePoints);
const compressedBinary = compress(subsetSnft.buffer);
return ExcalidrawFont.toBase64(compressedBinary.buffer);
} catch (e) {
console.error("Skipped glyph subsetting", e);
// Fallback to encoding whole font in case of errors
return ExcalidrawFont.toBase64(arrayBuffer);
}
}
private static async toBase64(arrayBuffer: ArrayBuffer) {
let base64: string;
if (typeof Buffer !== "undefined") {
// node + server-side
base64 = Buffer.from(arrayBuffer).toString("base64");
} else {
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
}
return `data:font/woff2;base64,${base64}`;
}
private static createUrls(uri: string): URL[] { private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) { if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts // no url for local fonts
@@ -118,15 +173,14 @@ export class ExcalidrawFont implements Font {
} }
// fallback url for bundled fonts // fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_PROD_URL)); urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
return urls; return urls;
} }
private static getFormat(url: URL) { private static getFormat(url: URL) {
try { try {
const pathname = new URL(url).pathname; const parts = new URL(url).pathname.split(".");
const parts = pathname.split(".");
if (parts.length === 1) { if (parts.length === 1) {
return ""; return "";
@@ -0,0 +1,202 @@
/**
* Modified version of hb-subset bindings from "subset-font" package https://github.com/papandreou/subset-font/blob/3f711c8aa29a426c7f22655861abfb976950f527/index.js
*
* CHANGELOG:
* - removed dependency on node APIs to work inside the browser
* - removed dependency on font fontverter for brotli compression
* - removed dependencies on lodash and p-limit
* - removed options for preserveNameIds, variationAxes, noLayoutClosure (not needed for now)
* - replaced text input with codepoints
* - rewritten in typescript and with esm modules
Copyright (c) 2012, Andreas Lind Petersen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the author nor the names of contributors may
be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// function HB_TAG(str) {
// return str.split("").reduce((a, ch) => {
// return (a << 8) + ch.charCodeAt(0);
// }, 0);
// }
function subset(
hbSubsetWasm: any,
heapu8: Uint8Array,
font: ArrayBuffer,
codePoints: ReadonlySet<number>,
) {
const input = hbSubsetWasm.hb_subset_input_create_or_fail();
if (input === 0) {
throw new Error(
"hb_subset_input_create_or_fail (harfbuzz) returned zero, indicating failure",
);
}
const fontBuffer = hbSubsetWasm.malloc(font.byteLength);
heapu8.set(new Uint8Array(font), fontBuffer);
// Create the face
const blob = hbSubsetWasm.hb_blob_create(
fontBuffer,
font.byteLength,
2, // HB_MEMORY_MODE_WRITABLE
0,
0,
);
const face = hbSubsetWasm.hb_face_create(blob, 0);
hbSubsetWasm.hb_blob_destroy(blob);
// Do the equivalent of --font-features=*
const layoutFeatures = hbSubsetWasm.hb_subset_input_set(
input,
6, // HB_SUBSET_SETS_LAYOUT_FEATURE_TAG
);
hbSubsetWasm.hb_set_clear(layoutFeatures);
hbSubsetWasm.hb_set_invert(layoutFeatures);
// if (preserveNameIds) {
// const inputNameIds = harfbuzzJsWasm.hb_subset_input_set(
// input,
// 4, // HB_SUBSET_SETS_NAME_ID
// );
// for (const nameId of preserveNameIds) {
// harfbuzzJsWasm.hb_set_add(inputNameIds, nameId);
// }
// }
// if (noLayoutClosure) {
// harfbuzzJsWasm.hb_subset_input_set_flags(
// input,
// harfbuzzJsWasm.hb_subset_input_get_flags(input) | 0x00000200, // HB_SUBSET_FLAGS_NO_LAYOUT_CLOSURE
// );
// }
// Add unicodes indices
const inputUnicodes = hbSubsetWasm.hb_subset_input_unicode_set(input);
for (const c of codePoints) {
hbSubsetWasm.hb_set_add(inputUnicodes, c);
}
// if (variationAxes) {
// for (const [axisName, value] of Object.entries(variationAxes)) {
// if (typeof value === "number") {
// // Simple case: Pin/instance the variation axis to a single value
// if (
// !harfbuzzJsWasm.hb_subset_input_pin_axis_location(
// input,
// face,
// HB_TAG(axisName),
// value,
// )
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `hb_subset_input_pin_axis_location (harfbuzz) returned zero when pinning ${axisName} to ${value}, indicating failure. Maybe the axis does not exist in the font?`,
// );
// }
// } else if (value && typeof value === "object") {
// // Complex case: Reduce the variation space of the axis
// if (
// typeof value.min === "undefined" ||
// typeof value.max === "undefined"
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `${axisName}: You must provide both a min and a max value when setting the axis range`,
// );
// }
// if (
// !harfbuzzJsWasm.hb_subset_input_set_axis_range(
// input,
// face,
// HB_TAG(axisName),
// value.min,
// value.max,
// // An explicit NaN makes harfbuzz use the existing default value, clamping to the new range if necessary
// value.default ?? NaN,
// )
// ) {
// harfbuzzJsWasm.hb_face_destroy(face);
// harfbuzzJsWasm.free(fontBuffer);
// throw new Error(
// `hb_subset_input_set_axis_range (harfbuzz) returned zero when setting the range of ${axisName} to [${value.min}; ${value.max}] and a default value of ${value.default}, indicating failure. Maybe the axis does not exist in the font?`,
// );
// }
// }
// }
// }
let subset;
try {
subset = hbSubsetWasm.hb_subset_or_fail(face, input);
if (subset === 0) {
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
throw new Error(
"hb_subset_or_fail (harfbuzz) returned zero, indicating failure. Maybe the input file is corrupted?",
);
}
} finally {
// Clean up
hbSubsetWasm.hb_subset_input_destroy(input);
}
// Get result blob
const result = hbSubsetWasm.hb_face_reference_blob(subset);
const offset = hbSubsetWasm.hb_blob_get_data(result, 0);
const subsetByteLength = hbSubsetWasm.hb_blob_get_length(result);
if (subsetByteLength === 0) {
hbSubsetWasm.hb_blob_destroy(result);
hbSubsetWasm.hb_face_destroy(subset);
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
throw new Error(
"Failed to create subset font, maybe the input file is corrupted?",
);
}
const subsetFont = new Uint8Array(
heapu8.subarray(offset, offset + subsetByteLength),
);
// Clean up
hbSubsetWasm.hb_blob_destroy(result);
hbSubsetWasm.hb_face_destroy(subset);
hbSubsetWasm.hb_face_destroy(face);
hbSubsetWasm.free(fontBuffer);
return subsetFont;
}
export default {
subset,
};
@@ -0,0 +1,58 @@
/**
* Lazy loads wasm and respective bindings for font subsetting based on the harfbuzzjs.
*/
let loadedWasm: ReturnType<typeof load> | null = null;
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
const [binary, bindings] = await Promise.all([
import("./hb-subset.wasm"),
import("./hb-subset.bindings"),
]);
WebAssembly.instantiate(binary.default).then((module) => {
try {
const harfbuzzJsWasm = module.instance.exports;
// @ts-expect-error since `.buffer` is custom prop
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
const hbSubset = {
subset: (
fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
) => {
return bindings.default.subset(
harfbuzzJsWasm,
heapu8,
fontBuffer,
codePoints,
);
},
};
resolve(hbSubset);
} catch (e) {
reject(e);
}
});
} catch (error) {
reject(error);
}
});
};
// lazy load the default export
export default (): ReturnType<typeof load> => {
if (!loadedWasm) {
loadedWasm = load();
}
return loadedWasm;
};
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
/**
* Lazy loads wasm and respective bindings for woff2 compression and decompression.
*/
type Vector = any;
let loadedWasm: ReturnType<typeof load> | null = null;
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{
compress: (buffer: ArrayBuffer) => Uint8Array;
decompress: (buffer: ArrayBuffer) => Uint8Array;
}> => {
return new Promise(async (resolve, reject) => {
try {
const [binary, bindings] = await Promise.all([
import("./woff2.wasm"),
import("./woff2.bindings"),
]);
// initializing the module manually, so that we could pass in the wasm binary
bindings
.default({ wasmBinary: binary.default })
.then(
(module: {
woff2Enc: (buffer: ArrayBuffer, byteLength: number) => Vector;
woff2Dec: (buffer: ArrayBuffer, byteLength: number) => Vector;
}) => {
try {
// re-map from internal vector into byte array
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
const arr = [];
for (let i = 0, l = vector.size(); i < l; i++) {
arr.push(vector.get(i));
}
return new Uint8Array(arr);
}
// re-exporting only compress and decompress functions (also avoids infinite loop inside emscripten bindings)
const woff2 = {
compress: (buffer: ArrayBuffer) =>
convertFromVecToUint8Array(
module.woff2Enc(buffer, buffer.byteLength),
),
decompress: (buffer: ArrayBuffer) =>
convertFromVecToUint8Array(
module.woff2Dec(buffer, buffer.byteLength),
),
};
resolve(woff2);
} catch (e) {
reject(e);
}
},
);
} catch (e) {
reject(e);
}
});
};
// lazy loaded default export
export default (): ReturnType<typeof load> => {
if (!loadedWasm) {
loadedWasm = load();
}
return loadedWasm;
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
import { useCallback, 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);
};
const resetCopyStatus = useCallback(() => {
setCopyStatus(null);
}, []);
return {
copyStatus,
resetCopyStatus,
onCopy,
};
};
@@ -18,6 +18,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
}, },
files: null, files: null,
renderEmbeddables: false, renderEmbeddables: false,
skipInliningFonts: true,
}); });
}; };
@@ -40,6 +41,7 @@ export const useLibraryItemSvg = (
// When there is no svg in cache export it and save to cache // When there is no svg in cache export it and save to cache
(async () => { (async () => {
const exportedSvg = await exportLibraryItemToSvg(elements); const exportedSvg = await exportLibraryItemToSvg(elements);
// TODO: should likely be removed for custom fonts
exportedSvg.querySelector(".style-fonts")?.remove(); exportedSvg.querySelector(".style-fonts")?.remove();
if (exportedSvg) { if (exportedSvg) {
+5
View File
@@ -213,6 +213,7 @@ export {
hashString, hashString,
isInvisiblySmallElement, isInvisiblySmallElement,
getNonDeletedElements, getNonDeletedElements,
getTextFromElements,
} from "./element"; } from "./element";
export { defaultLang, useI18n, languages } from "./i18n"; export { defaultLang, useI18n, languages } from "./i18n";
export { export {
@@ -271,6 +272,7 @@ export { MainMenu };
export { useDevice } from "./components/App"; export { useDevice } from "./components/App";
export { WelcomeScreen }; export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats";
export { DefaultSidebar } from "./components/DefaultSidebar"; export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialog } from "./components/TTDDialog/TTDDialog";
@@ -286,3 +288,6 @@ export {
isElementInsideBBox, isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox, elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/withinBounds"; } from "../utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
+10 -8
View File
@@ -168,6 +168,7 @@
"exportImage": "Export image...", "exportImage": "Export image...",
"export": "Save to...", "export": "Save to...",
"copyToClipboard": "Copy to clipboard", "copyToClipboard": "Copy to clipboard",
"copyLink": "Copy link",
"save": "Save to current file", "save": "Save to current file",
"saveAs": "Save as", "saveAs": "Save as",
"load": "Open", "load": "Open",
@@ -272,8 +273,7 @@
"laser": "Laser pointer", "laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools", "extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw", "mermaidToExcalidraw": "Mermaid to Excalidraw"
"magicSettings": "AI settings"
}, },
"element": { "element": {
"rectangle": "Rectangle", "rectangle": "Rectangle",
@@ -316,6 +316,7 @@
"placeImage": "Click to place the image, or click and drag to set its size manually", "placeImage": "Click to place the image, or click and drag to set its size manually",
"publishLibrary": "Publish your own library", "publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text", "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", "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
"eraserRevert": "Hold Alt to revert the elements marked for deletion", "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.", "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", "click": "click",
"deepSelect": "Deep select", "deepSelect": "Deep select",
"deepBoxSelect": "Deep select within box, and prevent dragging", "deepBoxSelect": "Deep select within box, and prevent dragging",
"createFlowchart": "Create a flowchart from a generic element",
"navigateFlowchart": "Navigate a flowchart",
"curvedArrow": "Curved arrow", "curvedArrow": "Curved arrow",
"curvedLine": "Curved line", "curvedLine": "Curved line",
"documentation": "Documentation", "documentation": "Documentation",
@@ -459,16 +462,15 @@
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
"element": "Element", "shapes": "Shapes",
"elements": "Elements",
"height": "Height", "height": "Height",
"scene": "Scene", "scene": "Scene",
"selected": "Selected", "selected": "Selected",
"storage": "Storage", "storage": "Storage",
"fullTitle": "Stats & Element properties", "fullTitle": "Canvas & Shape properties",
"title": "Stats", "title": "Properties",
"generalStats": "General stats", "generalStats": "General",
"elementProperties": "Element properties", "elementProperties": "Shape properties",
"total": "Total", "total": "Total",
"version": "Version", "version": "Version",
"versionCopy": "Click to copy", "versionCopy": "Click to copy",
+12 -2
View File
@@ -1,4 +1,9 @@
import type { NormalizedZoomValue, Point, Zoom } from "./types"; import type {
NormalizedZoomValue,
NullableGridSize,
Point,
Zoom,
} from "./types";
import { import {
DEFAULT_ADAPTIVE_RADIUS, DEFAULT_ADAPTIVE_RADIUS,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
@@ -275,7 +280,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
export const getGridPoint = ( export const getGridPoint = (
x: number, x: number,
y: number, y: number,
gridSize: number | null, gridSize: NullableGridSize,
): [number, number] => { ): [number, number] => {
if (gridSize) { if (gridSize) {
return [ return [
@@ -703,3 +708,8 @@ export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
export const clamp = (value: number, min: number, max: number) => { export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
}; };
export const round = (value: number, precision: number) => {
const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
};
+2
View File
@@ -113,6 +113,8 @@
"esbuild-sass-plugin": "2.16.0", "esbuild-sass-plugin": "2.16.0",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"import-meta-loader": "1.1.0", "import-meta-loader": "1.1.0",
"mini-css-extract-plugin": "2.6.1", "mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1", "postcss-loader": "7.0.1",
@@ -680,8 +680,11 @@ const _renderInteractiveScene = ({
} }
} }
if (appState.editingElement && isTextElement(appState.editingElement)) { if (
const textElement = allElementsMap.get(appState.editingElement.id) as appState.editingTextElement &&
isTextElement(appState.editingTextElement)
) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
if (textElement && !textElement.autoResize) { if (textElement && !textElement.autoResize) {
@@ -894,7 +897,7 @@ const _renderInteractiveScene = ({
!appState.viewModeEnabled && !appState.viewModeEnabled &&
showBoundingBox && showBoundingBox &&
// do not show transform handles when text is being edited // do not show transform handles when text is being edited
!isTextElement(appState.editingElement) !isTextElement(appState.editingTextElement)
) { ) {
renderTransformHandles( renderTransformHandles(
context, context,
+21 -1
View File
@@ -35,6 +35,7 @@ import type {
Zoom, Zoom,
InteractiveCanvasAppState, InteractiveCanvasAppState,
ElementsPendingErasure, ElementsPendingErasure,
PendingExcalidrawElements,
} from "../types"; } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
@@ -104,6 +105,7 @@ export const getRenderOpacity = (
element: ExcalidrawElement, element: ExcalidrawElement,
containingFrame: ExcalidrawFrameLikeElement | null, containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure, elementsPendingErasure: ElementsPendingErasure,
pendingNodes: Readonly<PendingExcalidrawElements> | null,
) => { ) => {
// multiplying frame opacity with element opacity to combine them // multiplying frame opacity with element opacity to combine them
// (e.g. frame 50% and element 50% opacity should result in 25% opacity) // (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) // (so that erasing always results in lower opacity than original)
if ( if (
elementsPendingErasure.has(element.id) || elementsPendingErasure.has(element.id) ||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
(containingFrame && elementsPendingErasure.has(containingFrame.id)) (containingFrame && elementsPendingErasure.has(containingFrame.id))
) { ) {
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100; opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
@@ -196,7 +199,7 @@ const generateElementCanvas = (
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas => { ): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
const padding = getCanvasPadding(element); const padding = getCanvasPadding(element);
@@ -207,6 +210,10 @@ const generateElementCanvas = (
zoom, zoom,
); );
if (!width || !height) {
return null;
}
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
@@ -537,6 +544,10 @@ const generateElementWithCanvas = (
appState, appState,
); );
if (!elementWithCanvas) {
return null;
}
elementWithCanvasCache.set(element, elementWithCanvas); elementWithCanvasCache.set(element, elementWithCanvas);
return elementWithCanvas; return elementWithCanvas;
@@ -672,6 +683,7 @@ export const renderElement = (
element, element,
getContainingFrame(element, elementsMap), getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure, renderConfig.elementsPendingErasure,
renderConfig.pendingFlowchartNodes,
); );
switch (element.type) { switch (element.type) {
@@ -738,6 +750,10 @@ export const renderElement = (
renderConfig, renderConfig,
appState, appState,
); );
if (!elementWithCanvas) {
return;
}
drawElementFromCanvas( drawElementFromCanvas(
elementWithCanvas, elementWithCanvas,
context, context,
@@ -877,6 +893,10 @@ export const renderElement = (
appState, appState,
); );
if (!elementWithCanvas) {
return;
}
const currentImageSmoothingStatus = context.imageSmoothingEnabled; const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if ( if (
@@ -0,0 +1,66 @@
import type { NewElementSceneRenderConfig } from "../scene/types";
import { throttleRAF } from "../utils";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { renderElement } from "./renderElement";
const _renderNewElementScene = ({
canvas,
rc,
newElement,
elementsMap,
allElementsMap,
scale,
appState,
renderConfig,
}: NewElementSceneRenderConfig) => {
if (canvas) {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
});
// Apply zoom
context.save();
context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") {
renderElement(
newElement,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
}
};
export const renderNewElementSceneThrottled = throttleRAF(
(config: NewElementSceneRenderConfig) => {
_renderNewElementScene(config);
},
{ trailing: true },
);
export const renderNewElementScene = (
renderConfig: NewElementSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
renderNewElementSceneThrottled(renderConfig);
return;
}
_renderNewElementScene(renderConfig);
};
+61 -19
View File
@@ -31,53 +31,77 @@ import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils"; import { throttleRAF } from "../utils";
import { getBoundTextElement } from "../element/textElement"; import { getBoundTextElement } from "../element/textElement";
const GridLineColor = {
Bold: "#dddddd",
Regular: "#e5e5e5",
} as const;
const strokeGrid = ( const strokeGrid = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
/** grid cell pixel size */
gridSize: number, gridSize: number,
/** setting to 1 will disble bold lines */
gridStep: number,
scrollX: number, scrollX: number,
scrollY: number, scrollY: number,
zoom: Zoom, zoom: Zoom,
width: number, width: number,
height: number, height: number,
) => { ) => {
const BOLD_LINE_FREQUENCY = 5; const offsetX = (scrollX % gridSize) - gridSize;
const offsetY = (scrollY % gridSize) - gridSize;
enum GridLineColor { const actualGridSize = gridSize * zoom.value;
Bold = "#cccccc",
Regular = "#e5e5e5",
}
const offsetX =
-Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize);
const offsetY =
-Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize);
const lineWidth = Math.min(1 / zoom.value, 1);
const spaceWidth = 1 / zoom.value; const spaceWidth = 1 / zoom.value;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.save(); context.save();
context.lineWidth = lineWidth;
// Offset rendering by 0.5 to ensure that 1px wide lines are crisp.
// We only do this when zoomed to 100% because otherwise the offset is
// fractional, and also visibly offsets the elements.
// We also do this per-axis, as each axis may already be offset by 0.5.
if (zoom.value === 1) {
context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5);
}
// vertical lines
for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
const isBold = const isBold =
Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0;
// don't render regular lines when zoomed out and they're barely visible
if (!isBold && actualGridSize < 10) {
continue;
}
const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.beginPath(); context.beginPath();
context.setLineDash(isBold ? [] : lineDash); context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(x, offsetY - gridSize); context.moveTo(x, offsetY - gridSize);
context.lineTo(x, offsetY + height + gridSize * 2); context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2));
context.stroke(); context.stroke();
} }
for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
const isBold = const isBold =
Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0;
if (!isBold && actualGridSize < 10) {
continue;
}
const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1);
context.lineWidth = lineWidth;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.beginPath(); context.beginPath();
context.setLineDash(isBold ? [] : lineDash); context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(offsetX - gridSize, y); context.moveTo(offsetX - gridSize, y);
context.lineTo(offsetX + width + gridSize * 2, y); context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y);
context.stroke(); context.stroke();
} }
context.restore(); context.restore();
@@ -199,10 +223,11 @@ const _renderStaticScene = ({
context.scale(appState.zoom.value, appState.zoom.value); context.scale(appState.zoom.value, appState.zoom.value);
// Grid // Grid
if (renderGrid && appState.gridSize) { if (renderGrid) {
strokeGrid( strokeGrid(
context, context,
appState.gridSize, appState.gridSize,
appState.gridStep,
appState.scrollX, appState.scrollX,
appState.scrollY, appState.scrollY,
appState.zoom, appState.zoom,
@@ -370,6 +395,23 @@ const _renderStaticScene = ({
console.error(error); 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 */ /** throttled to animation framerate */
+20 -8
View File
@@ -1,6 +1,7 @@
import { isElementInViewport } from "../element/sizeHelpers"; import { isElementInViewport } from "../element/sizeHelpers";
import { isImageElement } from "../element/typeChecks"; import { isImageElement } from "../element/typeChecks";
import type { import type {
ExcalidrawElement,
NonDeletedElementsMap, NonDeletedElementsMap,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
@@ -64,11 +65,13 @@ export class Renderer {
const getRenderableElements = ({ const getRenderableElements = ({
elements, elements,
editingElement, editingTextElement,
newElementId,
pendingImageElementId, pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingElement: AppState["editingElement"]; editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
@@ -83,12 +86,16 @@ export class Renderer {
} }
} }
if (newElementId === element.id) {
continue;
}
// we don't want to render text element that's being currently edited // we don't want to render text element that's being currently edited
// (it's rendered on remote only) // (it's rendered on remote only)
if ( if (
!editingElement || !editingTextElement ||
editingElement.type !== "text" || editingTextElement.type !== "text" ||
element.id !== editingElement.id element.id !== editingTextElement.id
) { ) {
elementsMap.set(element.id, element); elementsMap.set(element.id, element);
} }
@@ -105,7 +112,8 @@ export class Renderer {
scrollY, scrollY,
height, height,
width, width,
editingElement, editingTextElement,
newElementId,
pendingImageElementId, pendingImageElementId,
// cache-invalidation nonce // cache-invalidation nonce
sceneNonce: _sceneNonce, sceneNonce: _sceneNonce,
@@ -117,7 +125,10 @@ export class Renderer {
scrollY: AppState["scrollY"]; scrollY: AppState["scrollY"];
height: AppState["height"]; height: AppState["height"];
width: AppState["width"]; width: AppState["width"];
editingElement: AppState["editingElement"]; editingTextElement: AppState["editingTextElement"];
/** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>; sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => { }) => {
@@ -125,7 +136,8 @@ export class Renderer {
const elementsMap = getRenderableElements({ const elementsMap = getRenderableElements({
elements, elements,
editingElement, editingTextElement,
newElementId,
pendingImageElementId, pendingImageElementId,
}); });

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