Compare commits

..
Author SHA1 Message Date
Marcel Mraz 9c91cf93dd Glyph subsetting for SVG export 2024-08-08 18:31:50 +02:00
139 changed files with 2249 additions and 9467 deletions
+2
View File
@@ -17,6 +17,8 @@ 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/main-menu/DefaultItems.tsx) of the default items. Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/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 parseMermaidToExcalidraw(mermaidSyntax: string, { const { elements, files } = await parseMermaid(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": 20, "gridSize": null,
"viewBackgroundColor": "#ffffff" "viewBackgroundColor": "#ffffff"
}, },
+58 -40
View File
@@ -22,6 +22,7 @@ import { t } from "../packages/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, StoreAction,
reconcileElements, reconcileElements,
@@ -120,12 +121,6 @@ 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();
@@ -342,8 +337,6 @@ 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
@@ -369,23 +362,6 @@ 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;
@@ -646,11 +622,6 @@ 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>(
@@ -849,7 +820,6 @@ 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}
@@ -875,9 +845,64 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action> </OverwriteConfirmDialog.Action>
)} )}
</OverwriteConfirmDialog> </OverwriteConfirmDialog>
<AppFooter onChange={() => excalidrawAPI?.refresh()} /> <AppFooter />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />} <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");
}
}}
/>
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="collab-offline-warning">
@@ -1107,13 +1132,6 @@ const ExcalidrawWrapper = () => {
}, },
]} ]}
/> />
{isVisualDebuggerEnabled() && excalidrawAPI && (
<DebugCanvas
appState={excalidrawAPI.getAppState()}
scale={window.devicePixelRatio}
ref={debugCanvasRef}
/>
)}
</Excalidraw> </Excalidraw>
</div> </div>
); );
+33 -28
View File
@@ -9,7 +9,6 @@ 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 };
@@ -52,33 +51,39 @@ const CustomStats = (props: Props) => {
} }
return ( return (
<Stats.StatsRows order={-1}> <>
<Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow> <tr>
<Stats.StatsRow <th colSpan={2}>{t("stats.storage")}</th>
style={{ textAlign: "center", cursor: "pointer" }} </tr>
onClick={async () => { <tr>
try { <td>{t("stats.scene")}</td>
await copyTextToSystemClipboard(getVersion()); <td>{nFormatter(storageSizes.scene, 1)}</td>
props.setToast(t("toast.copyToClipboard")); </tr>
} catch {} <tr>
}} <td>{t("stats.total")}</td>
title={t("stats.versionCopy")} <td>{nFormatter(storageSizes.total, 1)}</td>
> </tr>
{timestamp} <tr>
<br /> <th colSpan={2}>{t("stats.version")}</th>
{hash} </tr>
</Stats.StatsRow> <tr>
<td
<Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow> colSpan={2}
<Stats.StatsRow columns={2}> style={{ textAlign: "center", cursor: "pointer" }}
<div>{t("stats.scene")}</div> onClick={async () => {
<div>{nFormatter(storageSizes.scene, 1)}</div> try {
</Stats.StatsRow> await copyTextToSystemClipboard(getVersion());
<Stats.StatsRow columns={2}> props.setToast(t("toast.copyToClipboard"));
<div>{t("stats.total")}</div> } catch {}
<div>{nFormatter(storageSizes.total, 1)}</div> }}
</Stats.StatsRow> title={t("stats.versionCopy")}
</Stats.StatsRows> >
{timestamp}
<br />
{hash}
</td>
</tr>
</>
); );
}; };
-1
View File
@@ -40,7 +40,6 @@ 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",
+14 -20
View File
@@ -116,26 +116,20 @@ class Portal {
} }
} }
let isChanged = false; this.collab.excalidrawAPI.updateScene({
const newElements = this.collab.excalidrawAPI elements: this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true; // this will signal collaborators to pull image data from server
// this will signal collaborators to pull image data from server // (using mutation instead of newElementWith otherwise it'd break
// (using mutation instead of newElementWith otherwise it'd break // in-progress dragging)
// in-progress dragging) return newElementWith(element, { status: "saved" });
return newElementWith(element, { status: "saved" }); }
} return element;
return element; }),
}); 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
@@ -0,0 +1,218 @@
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
@@ -1,159 +0,0 @@
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");
}
}}
/>
</>
);
};
+19 -23
View File
@@ -3,27 +3,23 @@ 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(() => {
({ onChange }: { onChange: () => void }) => { return (
return ( <Footer>
<Footer> <div
<div style={{
style={{ display: "flex",
display: "flex", gap: ".5rem",
gap: ".5rem", alignItems: "center",
alignItems: "center", }}
}} >
> {isExcalidrawPlusSignedUser ? (
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />} <ExcalidrawPlusAppLink />
{isExcalidrawPlusSignedUser ? ( ) : (
<ExcalidrawPlusAppLink /> <EncryptedIcon />
) : ( )}
<EncryptedIcon /> </div>
)} </Footer>
</div> );
</Footer> });
);
},
);
-20
View File
@@ -2,13 +2,11 @@ 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;
@@ -16,7 +14,6 @@ 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>
@@ -53,23 +50,6 @@ 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
@@ -1,293 +0,0 @@
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,6 +128,29 @@
<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); background: var(--color-success-lighter);
color: var(--color-success-text); color: var(--color-success);
& > svg { & > svg {
width: 0.875rem; width: 0.875rem;
+23 -13
View File
@@ -1,4 +1,5 @@
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";
@@ -13,6 +14,7 @@ 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";
@@ -22,7 +24,6 @@ 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";
@@ -62,11 +63,10 @@ const ActiveRoomDialog = ({
handleClose: () => void; handleClose: () => void;
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [, setJustCopied] = useState(false); const [justCopied, 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,16 +130,26 @@ const ActiveRoomDialog = ({
onClick={shareRoomLink} onClick={shareRoomLink}
/> />
)} )}
<FilledButton <Popover.Root open={justCopied}>
size="large" <Popover.Trigger asChild>
label={t("buttons.copyLink")} <FilledButton
icon={copyIcon} size="large"
status={copyStatus} label="Copy link"
onClick={() => { icon={copyIcon}
copyRoomLink(); onClick={copyRoomLink}
onCopy(); />
}} </Popover.Trigger>
/> <Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div> </div>
<div className="ShareDialog__active__description"> <div className="ShareDialog__active__description">
<p> <p>
+2 -13
View File
@@ -73,8 +73,8 @@ export default defineConfig({
}, },
workbox: { workbox: {
// Don't push fonts, locales and wasm to app precache // Don't push fonts and locales to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"], globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"), urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
@@ -108,17 +108,6 @@ 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,8 +39,6 @@ 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 |
+18 -24
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 } from "../types"; import type { AppState, NormalizedZoomValue } 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,7 +38,6 @@ 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",
@@ -105,8 +104,6 @@ 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:
@@ -247,7 +244,6 @@ 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;
@@ -255,21 +251,20 @@ 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(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP; Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1)); Math.max(zoomAdjustedToSteps, MIN_ZOOM),
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 1, viewportZoomFactor = 0.7,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
appState: Readonly<AppState>; appState: Readonly<AppState>;
@@ -294,9 +289,13 @@ export const zoomToFitBounds = ({
Math.min( Math.min(
appState.width / commonBoundsWidth, appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight, appState.height / commonBoundsHeight,
) * clamp(viewportZoomFactor, 0.1, 1); ) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
newZoomValue = getNormalizedZoom(newZoomValue); // Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width; let appStateWidth = appState.width;
@@ -315,14 +314,10 @@ 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( newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
bounds, width: appState.width,
{ 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 },
@@ -413,7 +408,6 @@ 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 { getTextFromElements, isTextElement } from "../element"; import { 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,8 +239,16 @@ 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(getTextFromElements(selectedElements)); copyTextToSystemClipboard(text);
} 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 { DEFAULT_GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
@@ -99,8 +99,8 @@ const duplicateElements = (
groupIdMap, groupIdMap,
element, element,
{ {
x: element.x + DEFAULT_GRID_SIZE / 2, x: element.x + GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2, y: element.y + GRID_SIZE / 2,
}, },
); );
duplicatedElementsMap.set(newElement.id, newElement); duplicatedElementsMap.set(newElement.id, newElement);
@@ -50,6 +50,7 @@ export const actionFinalize = register({
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
selectedLinearElement: null,
}, },
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
@@ -178,7 +179,7 @@ export const actionFinalize = register({
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingTextElement: null, editingElement: 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 { AppClassProperties, AppState } from "../types"; import type { 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,19 +13,17 @@ import type { Store } from "../store";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
const executeHistoryAction = ( const writeData = (
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.editingTextElement && !appState.editingElement &&
!appState.newElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
!appState.selectionElement && !appState.selectionElement
!app.flowChartCreator.isCreatingChart
) { ) {
const result = updater(); const result = updater();
@@ -55,7 +53,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) =>
executeHistoryAction(app, appState, () => writeData(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,
@@ -96,7 +94,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) =>
executeHistoryAction(app, appState, () => writeData(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.editingTextElement?.id element.id === appState.editingElement?.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 editingTextElement = appState.editingTextElement; const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements); const nonDeletedElements = getNonDeletedElements(elements);
let ret: T | null = null; let ret: T | null = null;
if (editingTextElement) { if (editingElement) {
ret = getAttribute(editingTextElement); ret = getAttribute(editingElement);
} }
if (!ret) { if (!ret) {
@@ -1073,20 +1073,19 @@ export const actionChangeFontFamily = register({
// open, populate the cache from scratch // open, populate the cache from scratch
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
const { editingTextElement } = appState; const { editingElement } = appState;
// still check type to be safe if (editingElement?.type === "text") {
if (editingTextElement?.type === "text") { // retrieve the latest version from the scene, as `editingElement` isn't mutated
// retrieve the latest version from the scene, as `editingTextElement` isn't mutated const latestEditingElement = app.scene.getElement(
const latesteditingTextElement = app.scene.getElement( editingElement.id,
editingTextElement.id,
); );
// inside the wysiwyg editor // inside the wysiwyg editor
cachedElementsRef.current.set( cachedElementsRef.current.set(
editingTextElement.id, editingElement.id,
newElementWith( newElementWith(
latesteditingTextElement || editingTextElement, latestEditingElement || editingElement,
{}, {},
true, true,
), ),
@@ -1,5 +1,6 @@
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";
@@ -12,21 +13,21 @@ export const actionToggleGridMode = register({
viewMode: true, viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => appState.gridModeEnabled, predicate: (appState) => !appState.gridSize,
}, },
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {
...appState, ...appState,
gridModeEnabled: !this.checked!(appState), gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.gridModeEnabled, checked: (appState: AppState) => appState.gridSize !== null,
predicate: (element, appState, props) => { predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined; return typeof 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),
gridModeEnabled: false, gridSize: null,
}, },
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", "export"]); const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
export const trackEvent = ( export const trackEvent = (
category: string, category: string,
+3 -9
View File
@@ -5,11 +5,9 @@ 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";
@@ -44,7 +42,7 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
newElement: null, newElement: null,
editingTextElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
activeTool: { activeTool: {
@@ -61,9 +59,7 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false, exportEmbedScene: false,
exportWithDarkMode: false, exportWithDarkMode: false,
fileHandle: null, fileHandle: null,
gridSize: DEFAULT_GRID_SIZE, gridSize: null,
gridStep: DEFAULT_GRID_STEP,
gridModeEnabled: false,
isBindingEnabled: true, isBindingEnabled: true,
defaultSidebarDockedPreference: false, defaultSidebarDockedPreference: false,
isLoading: false, isLoading: false,
@@ -165,7 +161,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 },
editingTextElement: { browser: false, export: false, server: false }, editingElement: { 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 },
@@ -178,8 +174,6 @@ 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: {
+19 -7
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,9 +104,7 @@ export const SelectedShapeActions = ({
) { ) {
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const isEditingTextOrNewElement = Boolean( const isEditing = Boolean(appState.editingElement);
appState.editingTextElement || appState.newElement,
);
const device = useDevice(); const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@@ -236,7 +234,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
)} )}
{!isEditingTextOrNewElement && targetElements.length > 0 && ( {!isEditing && targetElements.length > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -402,7 +400,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.mermaidToExcalidraw")} {t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.props.aiEnabled !== false && app.plugins.diagramToCode && ( {app.props.aiEnabled !== false && (
<> <>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
@@ -412,6 +410,20 @@ 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>
@@ -427,7 +439,7 @@ export const ZoomActions = ({
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
zoom: Zoom; zoom: Zoom;
}) => ( }) => (
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}> <Stack.Col gap={1} className="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: "13rem" }} style={{ maxWidth: "208px" }}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); focusPickerContent();
@@ -1,17 +0,0 @@
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,19 +1,5 @@
@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;
@@ -30,20 +16,11 @@
.Spinner { .Spinner {
--spinner-color: var(--color-surface-lowest); --spinner-color: var(--color-surface-lowest);
}
.ExcButton__statusIcon {
visibility: visible;
position: absolute; position: absolute;
visibility: visible;
width: 1.2rem;
height: 1.2rem;
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
} }
&.ExcButton--status-loading, &[disabled] {
&.ExcButton--status-success {
pointer-events: none; pointer-events: none;
.ExcButton__contents { .ExcButton__contents {
@@ -51,10 +28,6 @@
} }
} }
&[disabled] {
pointer-events: none;
}
&, &,
&__contents { &__contents {
display: flex; display: flex;
@@ -146,46 +119,6 @@
} }
} }
&--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,15 +5,9 @@ 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 = export type ButtonColor = "primary" | "danger" | "warning" | "muted";
| "primary"
| "danger"
| "warning"
| "muted"
| "success";
export type ButtonSize = "medium" | "large"; export type ButtonSize = "medium" | "large";
export type FilledButtonProps = { export type FilledButtonProps = {
@@ -21,7 +15,6 @@ 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;
@@ -44,7 +37,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
size = "medium", size = "medium",
fullWidth, fullWidth,
className, className,
status,
}, },
ref, ref,
) => { ) => {
@@ -54,11 +46,8 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
const ret = onClick?.(event); const ret = onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
// delay loading state to prevent flicker in case of quick response
const timer = window.setTimeout(() => {
setIsLoading(true);
}, 50);
try { try {
setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
@@ -67,15 +56,11 @@ 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(
@@ -83,7 +68,6 @@ 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,
)} )}
@@ -91,16 +75,10 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={_status === "loading" || _status === "success"} disabled={isLoading}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{_status === "loading" ? ( {isLoading && <Spinner />}
<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,16 +304,6 @@ 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,7 +9,6 @@ $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%;
+7 -35
View File
@@ -1,7 +1,6 @@
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,
@@ -11,8 +10,6 @@ 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;
@@ -21,12 +18,7 @@ interface HintViewerProps {
app: AppClassProperties; app: AppClassProperties;
} }
const getHints = ({ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
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;
@@ -87,7 +79,7 @@ const getHints = ({
return t("hints.text_selected"); return t("hints.text_selected");
} }
if (appState.editingTextElement) { if (appState.editingElement && isTextElement(appState.editingElement)) {
return t("hints.text_editing"); return t("hints.text_editing");
} }
@@ -95,13 +87,13 @@ const getHints = ({
if ( if (
appState.selectionElement && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.editingTextElement && !appState.editingElement &&
!appState.editingLinearElement !appState.editingLinearElement
) { ) {
return t("hints.deepBoxSelect"); return t("hints.deepBoxSelect");
} }
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping"); return t("hints.disableSnapping");
} }
@@ -123,19 +115,6 @@ const getHints = ({
!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");
} }
} }
@@ -150,24 +129,17 @@ export const HintViewer = ({
device, device,
app, app,
}: HintViewerProps) => { }: HintViewerProps) => {
const hints = getHints({ let hint = getHints({
appState, appState,
isMobile, isMobile,
device, device,
app, app,
}); });
if (!hint) {
if (!hints) {
return null; return null;
} }
const hint = Array.isArray(hints) hint = getShortcutKey(hint);
? hints
.map((hint) => {
return getShortcutKey(hint).replace(/\. ?$/, "");
})
.join(". ")
: getShortcutKey(hints);
return ( return (
<div className="HintViewer"> <div className="HintViewer">
@@ -35,7 +35,6 @@ 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")!;
@@ -90,21 +89,6 @@ 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,
@@ -121,7 +105,6 @@ const ImageExportModal = ({
if (!maxWidth) { if (!maxWidth) {
return; return;
} }
exportToCanvas({ exportToCanvas({
elements: exportedElements, elements: exportedElements,
appState: { appState: {
@@ -311,17 +294,11 @@ const ImageExportModal = ({
<FilledButton <FilledButton
className="ImageExportModal__settings__buttons__button" className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")} label={t("imageExportDialog.title.copyPngToClipboard")}
status={copyStatus} onClick={() =>
onClick={async () => { onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
await onExportImage( exportingFrame,
EXPORT_IMAGE_TYPES.clipboard, })
exportedElements, }
{
exportingFrame,
},
);
onCopy();
}}
icon={copyIcon} icon={copyIcon}
> >
{t("imageExportDialog.button.copyPngToClipboard")} {t("imageExportDialog.button.copyPngToClipboard")}
@@ -27,6 +27,99 @@
& > * { & > * {
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 {
+33 -1
View File
@@ -60,6 +60,7 @@ 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";
@@ -84,6 +85,14 @@ 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<{
@@ -140,6 +149,10 @@ 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();
@@ -347,7 +360,7 @@ const LayerUI = ({
)} )}
{shouldShowStats && ( {shouldShowStats && (
<Stats <Stats
app={app} scene={app.scene}
onClose={() => { onClose={() => {
actionManager.executeAction(actionToggleStats); actionManager.executeAction(actionToggleStats);
}} }}
@@ -469,6 +482,25 @@ 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()}
@@ -0,0 +1,18 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}
@@ -0,0 +1,160 @@
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,7 +133,6 @@ 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); background: var(--color-success-lighter);
color: var(--color-success-text); color: var(--color-success);
& > svg { & > svg {
width: 0.875rem; width: 0.875rem;
@@ -1,4 +1,5 @@
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";
@@ -6,8 +7,7 @@ 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 { useCopyStatus } from "../hooks/useCopiedIndicator"; import { copyIcon, tablerCheckIcon } from "./icons";
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 [, setJustCopied] = useState(false); const [justCopied, 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,16 +60,26 @@ export const ShareableLinkDialog = ({
value={link} value={link}
selectOnRender selectOnRender
/> />
<FilledButton <Popover.Root open={justCopied}>
size="large" <Popover.Trigger asChild>
label={t("buttons.copyLink")} <FilledButton
icon={copyIcon} size="large"
status={copyStatus} label="Copy link"
onClick={() => { icon={copyIcon}
onCopy(); onClick={copyRoomLink}
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")}
+1 -3
View File
@@ -6,18 +6,16 @@ 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 ${className}`}> <div className="Spinner">
<svg <svg
viewBox="0 0 100 100" viewBox="0 0 100 100"
style={{ style={{
@@ -1,67 +0,0 @@
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,11 +31,7 @@ const Collapsible = ({
{label} {label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} /> <InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
</div> </div>
{open && ( {open && <>{children}</>}
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
</div>
)}
</> </>
); );
}; };
@@ -23,6 +23,7 @@ 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-md); border-radius: var(--border-radius-lg);
} }
} }
@@ -18,18 +18,17 @@
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;
padding: 0 0.5rem 0 0.75rem; width: 2rem;
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-md) 0 0 var(--border-radius-md); border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color); border-right: 1px solid var(--default-border-color);
border-left: 0; border-left: 0;
} }
@@ -56,11 +55,11 @@
letter-spacing: 0.4px; letter-spacing: 0.4px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md); border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color); border-left: 1px solid var(--default-border-color);
border-right: 0; border-right: 0;
} }
@@ -29,7 +29,6 @@ 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<
@@ -46,8 +45,6 @@ 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 = <
@@ -64,7 +61,6 @@ 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);
@@ -130,49 +126,27 @@ 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 callbacksRef = useRef< const handleInputValueRef = useRef(handleInputValue);
Partial<{ handleInputValueRef.current = handleInputValue;
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) {
callbacks.handleInputValue?.( handleInputValueRef.current(
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
@@ -198,8 +172,6 @@ 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;
@@ -224,43 +196,35 @@ const StatsDragInput = <
const originalAppState: AppState = cloneJSON(appState); const originalAppState: AppState = cloneJSON(appState);
let accumulatedChange = 0; let accumulatedChange: number | null = null;
let stepChange = 0;
document.body.classList.add("excalidraw-cursor-resize");
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
if (!accumulatedChange) {
accumulatedChange = 0;
}
if ( if (
lastPointer && lastPointer &&
originalElementsMap !== null && originalElementsMap !== null &&
originalElements !== null originalElements !== null &&
accumulatedChange !== null
) { ) {
const instantChange = event.clientX - lastPointer.x; const instantChange = event.clientX - lastPointer.x;
accumulatedChange += instantChange;
if (instantChange !== 0) { dragInputCallback({
stepChange += instantChange; accumulatedChange,
instantChange,
if (Math.abs(stepChange) >= sensitivity) { originalElements,
stepChange = originalElementsMap,
Math.sign(stepChange) * shouldKeepAspectRatio: shouldKeepAspectRatio!!,
Math.floor(Math.abs(stepChange) / sensitivity); shouldChangeByStepSize: event.shiftKey,
property,
accumulatedChange += stepChange; scene,
originalAppState,
dragInputCallback({ });
accumulatedChange,
instantChange: stepChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
});
stepChange = 0;
}
}
} }
lastPointer = { lastPointer = {
@@ -269,31 +233,27 @@ 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(EVENT.POINTER_UP, onPointerUp, false); window.addEventListener(
EVENT.POINTER_UP,
() => {
window.removeEventListener(
EVENT.POINTER_MOVE,
onPointerMove,
false,
);
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
lastPointer = null;
accumulatedChange = null;
originalElements = null;
originalElementsMap = null;
document.body.classList.remove("excalidraw-cursor-resize");
},
false,
);
} }
}} }}
onPointerEnter={() => { onPointerEnter={() => {
@@ -1,72 +0,0 @@
.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%;
}
}
}
+133 -221
View File
@@ -2,16 +2,13 @@ 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 { import type { AppState, ExcalidrawProps } from "../../types";
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";
@@ -20,18 +17,14 @@ 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 {
app: AppClassProperties; scene: Scene;
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
} }
@@ -40,12 +33,11 @@ const STATS_TIMEOUT = 50;
export const Stats = (props: StatsProps) => { export const Stats = (props: StatsProps) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const sceneNonce = props.app.scene.getSceneNonce() || 1; const sceneNonce = props.scene.getSceneNonce() || 1;
const selectedElements = props.app.scene.getSelectedElements({ const selectedElements = props.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
const gridModeEnabled = isGridModeEnabled(props.app);
return ( return (
<StatsInner <StatsInner
@@ -53,71 +45,23 @@ 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(
({ ({
app, scene,
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();
@@ -162,7 +106,7 @@ export const StatsInner = memo(
}, [selectedElements, appState]); }, [selectedElements, appState]);
return ( return (
<div className="exc-stats"> <div className="Stats">
<Island padding={3}> <Island padding={3}>
<div className="title"> <div className="title">
<h2>{t("stats.title")}</h2> <h2>{t("stats.title")}</h2>
@@ -177,6 +121,7 @@ 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,
@@ -185,36 +130,26 @@ export const StatsInner = memo(
}) })
} }
> >
<StatsRows> <table>
<StatsRow heading>{t("stats.scene")}</StatsRow> <tbody>
<StatsRow columns={2}> <tr>
<div>{t("stats.shapes")}</div> <th colSpan={2}>{t("stats.scene")}</th>
<div>{elements.length}</div> </tr>
</StatsRow> <tr>
<StatsRow columns={2}> <td>{t("stats.elements")}</td>
<div>{t("stats.width")}</div> <td>{elements.length}</td>
<div>{sceneDimension.width}</div> </tr>
</StatsRow> <tr>
<StatsRow columns={2}> <td>{t("stats.width")}</td>
<div>{t("stats.height")}</div> <td>{sceneDimension.width}</td>
<div>{sceneDimension.height}</div> </tr>
</StatsRow> <tr>
{gridModeEnabled && ( <td>{t("stats.height")}</td>
<> <td>{sceneDimension.height}</td>
<StatsRow heading>Canvas</StatsRow> </tr>
<StatsRow> {renderCustomStats?.(elements, appState)}
<CanvasGrid </tbody>
property="gridStep" </table>
scene={scene}
appState={appState}
setAppState={setAppState}
/>
</StatsRow>
</>
)}
</StatsRows>
{renderCustomStats?.(elements, appState)}
</Collapsible> </Collapsible>
{selectedElements.length > 0 && ( {selectedElements.length > 0 && (
@@ -232,6 +167,7 @@ export const StatsInner = memo(
openTrigger={() => openTrigger={() =>
setAppState((state) => { setAppState((state) => {
return { return {
...state,
stats: { stats: {
open: true, open: true,
panels: panels:
@@ -241,139 +177,117 @@ export const StatsInner = memo(
}) })
} }
> >
<StatsRows> {singleElement && (
{singleElement && ( <div className="sectionContent">
<> <div className="elementType">
<StatsRow heading data-testid="stats-element-type"> {t(`element.${singleElement.type}`)}
{t(`element.${singleElement.type}`)} </div>
</StatsRow>
<StatsRow> <div className="statsItem">
<Position <Position
element={singleElement} element={singleElement}
property="x" property="x"
elementsMap={elementsMap} elementsMap={elementsMap}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow> <Position
<StatsRow> element={singleElement}
<Position property="y"
element={singleElement} elementsMap={elementsMap}
property="y" scene={scene}
elementsMap={elementsMap} appState={appState}
scene={scene} />
appState={appState} <Dimension
/> property="width"
</StatsRow> element={singleElement}
<StatsRow> scene={scene}
<Dimension appState={appState}
property="width" />
element={singleElement} <Dimension
scene={scene} property="height"
appState={appState} element={singleElement}
/> scene={scene}
</StatsRow> appState={appState}
<StatsRow> />
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
{!isElbowArrow(singleElement) && ( {!isElbowArrow(singleElement) && (
<StatsRow> <Angle
<Angle property="angle"
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
)}
<StatsRow>
<FontSize
property="fontSize"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
</StatsRow>
</>
)}
{multipleElements && (
<>
{elementsAreInSameGroup(multipleElements) && (
<StatsRow heading>{t("element.group")}</StatsRow>
)} )}
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/>
</div>
</div>
)}
<StatsRow columns={2} style={{ margin: "0.3125rem 0" }}> {multipleElements && (
<div>{t("stats.shapes")}</div> <div className="sectionContent">
<div>{selectedElements.length}</div> {elementsAreInSameGroup(multipleElements) && (
</StatsRow> <div className="elementType">{t("element.group")}</div>
)}
<StatsRow> <div className="elementsCount">
<MultiPosition <div>{t("stats.elements")}</div>
property="x" <div>{selectedElements.length}</div>
elements={multipleElements} </div>
elementsMap={elementsMap}
atomicUnits={atomicUnits} <div className="statsItem">
scene={scene} <MultiPosition
appState={appState} property="x"
/> elements={multipleElements}
</StatsRow> elementsMap={elementsMap}
<StatsRow> atomicUnits={atomicUnits}
<MultiPosition scene={scene}
property="y" appState={appState}
elements={multipleElements} />
elementsMap={elementsMap} <MultiPosition
atomicUnits={atomicUnits} property="y"
scene={scene} elements={multipleElements}
appState={appState} elementsMap={elementsMap}
/> atomicUnits={atomicUnits}
</StatsRow> scene={scene}
<StatsRow> appState={appState}
<MultiDimension />
property="width" <MultiDimension
elements={multipleElements} property="width"
elementsMap={elementsMap} elements={multipleElements}
atomicUnits={atomicUnits} elementsMap={elementsMap}
scene={scene} atomicUnits={atomicUnits}
appState={appState} scene={scene}
/> appState={appState}
</StatsRow> />
<StatsRow> <MultiDimension
<MultiDimension property="height"
property="height" elements={multipleElements}
elements={multipleElements} elementsMap={elementsMap}
elementsMap={elementsMap} atomicUnits={atomicUnits}
atomicUnits={atomicUnits} scene={scene}
scene={scene} appState={appState}
appState={appState} />
/> <MultiAngle
</StatsRow> property="angle"
<StatsRow> elements={multipleElements}
<MultiAngle scene={scene}
property="angle" appState={appState}
elements={multipleElements} />
scene={scene} <MultiFontSize
appState={appState} property="fontSize"
/> elements={multipleElements}
</StatsRow> scene={scene}
<StatsRow> appState={appState}
<MultiFontSize elementsMap={elementsMap}
property="fontSize" />
elements={multipleElements} </div>
scene={scene} </div>
appState={appState} )}
elementsMap={elementsMap}
/>
</StatsRow>
</>
)}
</StatsRows>
</Collapsible> </Collapsible>
</div> </div>
)} )}
@@ -385,9 +299,7 @@ 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,6 +32,21 @@ 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",
@@ -39,7 +54,7 @@ const testInputProperty = (
initialValue: number, initialValue: number,
nextValue: number, nextValue: number,
) => { ) => {
const input = UI.queryStatsProperty(label)?.querySelector( const input = getStatsProperty(label)?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -121,7 +136,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 = UI.queryStatsProperty("X")?.querySelector( const inputX = getStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -133,7 +148,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 = UI.queryStatsProperty("A")?.querySelector( const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -144,7 +159,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 = UI.queryStatsProperty("X")?.querySelector( const inputX = getStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -156,7 +171,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 = UI.queryStatsProperty("A")?.querySelector( const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -210,14 +225,18 @@ 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 = queryByTestId(elementStats!, "stats-element-type"); const elementType = elementStats?.querySelector(".elementType");
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(
stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`), properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
),
).toBeDefined(); ).toBeDefined();
}); });
}); });
@@ -238,7 +257,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 = UI.queryStatsProperty("W")?.querySelector( const input = getStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -268,11 +287,11 @@ describe("stats for a generic element", () => {
rectangle.angle, rectangle.angle,
); );
const xInput = UI.queryStatsProperty("X")?.querySelector( const xInput = getStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
const yInput = UI.queryStatsProperty("Y")?.querySelector( const yInput = getStatsProperty("Y")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -398,7 +417,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 = UI.queryStatsProperty("F")?.querySelector( const input = getStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).toBeDefined(); expect(input).toBeDefined();
@@ -407,9 +426,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 = UI.queryStatsProperty("W")?.querySelector(".drag-input"); const width = getStatsProperty("W")?.querySelector(".drag-input");
expect(width).toBeUndefined(); expect(width).toBeUndefined();
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input"); const height = getStatsProperty("H")?.querySelector(".drag-input");
expect(height).toBeUndefined(); expect(height).toBeUndefined();
// min font size is 4 // min font size is 4
@@ -437,7 +456,7 @@ describe("stats for a non-generic element", () => {
expect(elementStats).toBeDefined(); expect(elementStats).toBeDefined();
// cannot change angle // cannot change angle
const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input"); const angle = getStatsProperty("A")?.querySelector(".drag-input");
expect(angle).toBeUndefined(); expect(angle).toBeUndefined();
// can change width or height // can change width or height
@@ -487,7 +506,7 @@ describe("stats for a non-generic element", () => {
API.setElements([container, text]); API.setElements([container, text]);
API.setSelectedElements([container]); API.setSelectedElements([container]);
const fontSize = UI.queryStatsProperty("F")?.querySelector( const fontSize = getStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
@@ -551,15 +570,15 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const width = UI.queryStatsProperty("W")?.querySelector( const width = getStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width?.value).toBe("Mixed"); expect(width?.value).toBe("Mixed");
const height = UI.queryStatsProperty("H")?.querySelector( const height = getStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height?.value).toBe("Mixed"); expect(height?.value).toBe("Mixed");
const angle = UI.queryStatsProperty("A")?.querySelector( const angle = getStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
@@ -610,25 +629,25 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const width = UI.queryStatsProperty("W")?.querySelector( const width = getStatsProperty("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 = UI.queryStatsProperty("H")?.querySelector( const height = getStatsProperty("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 = UI.queryStatsProperty("A")?.querySelector( const angle = getStatsProperty("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 = UI.queryStatsProperty("F")?.querySelector( const fontSize = getStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).toBeDefined(); expect(fontSize).toBeDefined();
@@ -673,7 +692,7 @@ describe("stats for multiple elements", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
const x = UI.queryStatsProperty("X")?.querySelector( const x = getStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -686,7 +705,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 = UI.queryStatsProperty("Y")?.querySelector( const y = getStatsProperty("Y")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
@@ -699,13 +718,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 = UI.queryStatsProperty("W")?.querySelector( const width = getStatsProperty("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 = UI.queryStatsProperty("H")?.querySelector( const height = getStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).toBeDefined(); expect(height).toBeDefined();
@@ -41,8 +41,7 @@ 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,7 +7,10 @@ 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();
@@ -36,6 +39,13 @@ 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,
editingTextElement: appState.editingTextElement, editingElement: appState.editingElement,
}); });
const areEqual = ( const areEqual = (
@@ -1,56 +0,0 @@
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,7 +101,6 @@ 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,7 +13,6 @@ const DropdownMenuItemLink = ({
onSelect, onSelect,
className = "", className = "",
selected, selected,
rel = "noreferrer",
...rest ...rest
}: { }: {
href: string; href: string;
@@ -23,7 +22,6 @@ 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);
+2 -4
View File
@@ -112,7 +112,6 @@ 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",
}; };
/** /**
@@ -180,8 +179,7 @@ export const COLOR_VOICE_CALL = "#a2f1a6";
export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const DEFAULT_GRID_SIZE = 20; export const GRID_SIZE = 20; // TODO make it configurable?
export const DEFAULT_GRID_STEP = 5;
export const IMAGE_MIME_TYPES = { export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml", svg: "image/svg+xml",
@@ -236,7 +234,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; export const MAX_ZOOM = 30.0;
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: 12.5rem; width: 200px;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
+2 -8
View File
@@ -129,14 +129,8 @@
--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: #cafccc; --color-success-lighter: #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
@@ -0,0 +1,105 @@
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.editingTextElement?.id || (local.id === localAppState.editingElement?.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
+16 -28
View File
@@ -10,7 +10,12 @@ import type {
PointBinding, PointBinding,
StrokeRoundness, StrokeRoundness,
} from "../element/types"; } from "../element/types";
import type { AppState, BinaryFiles, LibraryItem } from "../types"; import type {
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import type { ImportedDataState, LegacyAppState } from "./types"; import type { ImportedDataState, LegacyAppState } from "./types";
import { import {
getNonDeletedElements, getNonDeletedElements,
@@ -34,17 +39,11 @@ 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 { import { getUpdatedTimestamp, updateActiveTool } from "../utils";
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement"; import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -52,12 +51,6 @@ 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,
@@ -113,7 +106,7 @@ const repairBinding = (
...binding, ...binding,
focus: binding.focus || 0, focus: binding.focus || 0,
fixedPoint: isElbowArrow(element) fixedPoint: isElbowArrow(element)
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0]) ? binding.fixedPoint ?? ([0, 0] as [number, number])
: null, : null,
}; };
}; };
@@ -620,24 +613,19 @@ 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:
value: getNormalizedZoom( typeof appState.zoom === "number"
isFiniteNumber(appState.zoom) ? {
? appState.zoom value: appState.zoom as NormalizedZoomValue,
: 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,
),
}; };
}; };
+13 -30
View File
@@ -41,7 +41,6 @@ import {
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -72,7 +71,6 @@ 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>
@@ -753,8 +751,7 @@ export const bindPointToSnapToElementOutline = (
const aabb = bindableElement && aabbForElement(bindableElement); const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) { if (bindableElement && aabb) {
// TODO: Dirty hacks until tangents are properly calculated // TODO: Dirty hack until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point);
const intersections = [ const intersections = [
...intersectElementWithLine( ...intersectElementWithLine(
bindableElement, bindableElement,
@@ -770,14 +767,18 @@ 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 = Math.abs( const dist = distanceToBindableElement(bindableElement, point, elementsMap);
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;
@@ -999,7 +1000,7 @@ const updateBoundPoint = (
if (isElbowArrow(linearElement)) { if (isElbowArrow(linearElement)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? binding.fixedPoint ??
calculateFixedPointForElbowArrowBinding( calculateFixedPointForElbowArrowBinding(
linearElement, linearElement,
bindableElement, bindableElement,
@@ -1112,12 +1113,12 @@ export const calculateFixedPointForElbowArrowBinding = (
) as Point; ) as Point;
return { return {
fixedPoint: normalizeFixedPoint([ fixedPoint: [
(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],
}; };
}; };
@@ -1604,10 +1605,6 @@ 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));
@@ -2174,8 +2171,7 @@ export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
) => { ) => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = 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),
@@ -2229,16 +2225,3 @@ 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;
};
+2 -3
View File
@@ -738,7 +738,6 @@ 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];
@@ -749,10 +748,10 @@ export const getCommonBounds = (
let minY = Infinity; let minY = Infinity;
let maxY = -Infinity; let maxY = -Infinity;
const _elementsMap = elementsMap || arrayToMap(elements); const 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);
+26 -51
View File
@@ -4,12 +4,7 @@ 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 { import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
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";
@@ -31,7 +26,7 @@ export const dragSelectedElements = (
x: number; x: number;
y: number; y: number;
}, },
gridSize: NullableGridSize, gridSize: AppState["gridSize"],
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
@@ -106,7 +101,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: NullableGridSize, gridSize: AppState["gridSize"],
): { 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;
@@ -159,42 +154,26 @@ export const getDragOffsetXY = (
return [x - x1, y - y1]; return [x - x1, y - y1];
}; };
export const dragNewElement = ({ export const dragNewElement = (
newElement, newElement: NonDeletedExcalidrawElement,
elementType, elementType: AppState["activeTool"]["type"],
originX, originX: number,
originY, originY: number,
x, x: number,
y, y: number,
width, width: number,
height, height: number,
shouldMaintainAspectRatio, shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter, shouldResizeFromCenter: boolean,
zoom, zoom: NormalizedZoomValue,
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;
@@ -258,16 +237,12 @@ export const dragNewElement = ({
} }
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
mutateElement( mutateElement(newElement, {
newElement, x: newX + (originOffset?.x ?? 0),
{ y: newY + (originOffset?.y ?? 0),
x: newX + (originOffset?.x ?? 0), width,
y: newY + (originOffset?.y ?? 0), height,
width, ...textAutoResize,
height, });
...textAutoResize,
},
informMutation,
);
} }
}; };
@@ -1,404 +0,0 @@
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
@@ -1,698 +0,0 @@
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, getTextFromElements } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
getPerfectElementSize, getPerfectElementSize,
getLockedLinearCursorAlignSize, getLockedLinearCursorAlignSize,
@@ -36,8 +36,6 @@ import type {
AppState, AppState,
PointerCoords, PointerCoords,
InteractiveCanvasAppState, InteractiveCanvasAppState,
AppClassProperties,
NullableGridSize,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -46,7 +44,7 @@ import {
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { toBrandedType, tupleToCoors } from "../utils"; import { tupleToCoors } from "../utils";
import { import {
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
@@ -211,7 +209,7 @@ export class LinearElementEditor {
/** @returns whether point was dragged */ /** @returns whether point was dragged */
static handlePointDragging( static handlePointDragging(
event: PointerEvent, event: PointerEvent,
app: AppClassProperties, appState: AppState,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: ( maybeSuggestBinding: (
@@ -281,7 +279,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
referencePoint, referencePoint,
[scenePointerX, scenePointerY], [scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -301,7 +299,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 : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
); );
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
@@ -317,7 +315,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 : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
) )
: ([ : ([
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
@@ -697,7 +695,7 @@ export class LinearElementEditor {
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
app: AppClassProperties, appState: AppState,
store: Store, store: Store,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
@@ -707,7 +705,6 @@ 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();
@@ -744,7 +741,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, {
@@ -755,7 +752,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
), ),
], ],
}); });
@@ -879,10 +876,9 @@ export class LinearElementEditor {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
app: AppClassProperties, appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null { ): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return null; return null;
} }
@@ -919,7 +915,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
lastCommittedPoint, lastCommittedPoint,
[scenePointerX, scenePointerY], [scenePointerX, scenePointerY],
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
); );
newPoint = [ newPoint = [
@@ -934,7 +930,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
: app.getEffectiveGridSize(), : appState.gridSize,
); );
} }
@@ -1069,7 +1065,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap, elementsMap: ElementsMap,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
gridSize: NullableGridSize, gridSize: number | null,
): 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);
@@ -1367,7 +1363,7 @@ export class LinearElementEditor {
static addMidpoint( static addMidpoint(
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords, pointerCoords: PointerCoords,
app: AppClassProperties, appState: AppState,
snapToGrid: boolean, snapToGrid: boolean,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ) {
@@ -1392,7 +1388,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null, snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
); );
const points = [ const points = [
...element.points.slice(0, segmentMidpoint.index!), ...element.points.slice(0, segmentMidpoint.index!),
@@ -1451,15 +1447,9 @@ export class LinearElementEditor {
: null; : null;
} }
const mergedElementsMap = options?.changedElements
? toBrandedType<SceneElementsMap>(
new Map([...elementsMap, ...options.changedElements]),
)
: elementsMap;
mutateElbowArrow( mutateElbowArrow(
element, element,
mergedElementsMap, elementsMap,
nextPoints, nextPoints,
[offsetX, offsetY], [offsetX, offsetY],
bindings, bindings,
@@ -1489,7 +1479,7 @@ export class LinearElementEditor {
elementsMap: ElementsMap, elementsMap: ElementsMap,
referencePoint: Point, referencePoint: Point,
scenePointer: Point, scenePointer: Point,
gridSize: NullableGridSize, gridSize: number | null,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
+1 -2
View File
@@ -375,13 +375,12 @@ 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: opts.pressures || [], pressures: [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null, lastCommittedPoint: null,
}; };
+16 -1
View File
@@ -22,6 +22,21 @@ 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();
@@ -178,7 +193,7 @@ describe("elbow arrow ui", () => {
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = UI.queryStatsProperty("A")?.querySelector( const inputAngle = getStatsProperty("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.editingTextElement || (appState.editingElement ||
(appState.activeTool.type !== "selection" && (appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" && appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" && appState.activeTool.type !== "hand" &&
+1 -44
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 { getCommonBounds, getElementBounds } from "./bounds"; import { 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,49 +55,6 @@ 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,19 +886,3 @@ 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.editingTextElement?.id).toBe(text.id); expect(h.state.editingElement?.id).toBe(text.id);
expect( expect(
(h.state.editingTextElement as ExcalidrawTextElement).containerId, (h.state.editingElement as ExcalidrawTextElement).containerId,
).toBe(null); ).toBe(null);
}); });
@@ -105,7 +105,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingTextElement?.id).toBe(boundText2.id); expect(h.state.editingElement?.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.editingTextElement?.id).toBe(text.id); expect(h.state.editingElement?.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.editingTextElement?.id).toBe(boundText.id); expect(h.state.editingElement?.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.editingTextElement?.id).toBe(text.id); expect(h.state.editingElement?.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.editingTextElement?.id).toBe(text.id); expect(h.state.editingElement?.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.editingTextElement?.id).toBe(text.id); expect(h.state.editingElement?.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.editingTextElement).toBe(null); expect(h.state.editingElement).toBe(null);
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
+15 -49
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants"; import { CLASSES, isSafari } from "../constants";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -38,11 +38,7 @@ import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
} from "../actions/actionProperties"; } from "../actions/actionProperties";
import { import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
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";
@@ -361,16 +357,7 @@ export const textWysiwyg = ({
}; };
editable.oninput = () => { editable.oninput = () => {
const normalized = normalizeText(editable.value); onChange(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);
}; };
} }
@@ -383,10 +370,6 @@ 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)) {
@@ -601,7 +584,6 @@ 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();
}; };
@@ -628,29 +610,10 @@ 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");
@@ -658,14 +621,17 @@ export const textWysiwyg = ({
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
event.target.closest( event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isPropertiesTrigger isPropertiesTrigger
) { ) {
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);
} 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
@@ -684,7 +650,7 @@ export const textWysiwyg = ({
}; };
// handle updates of textElement properties of editing element // handle updates of textElement properties of editing element
const unbindUpdate = app.scene.onUpdate(() => { const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
updateWysiwygStyle(); updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest( const isPopupOpened = !!document.activeElement?.closest(
".properties-content", ".properties-content",
@@ -694,10 +660,6 @@ export const textWysiwyg = ({
} }
}); });
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
updateWysiwygStyle();
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let isDestroyed = false; let isDestroyed = false;
@@ -728,6 +690,10 @@ 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")!
+1 -51
View File
@@ -1,9 +1,7 @@
import type { LineSegment } from "../../utils";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import type { ElementOrToolType, Point } from "../types"; import type { ElementOrToolType } 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,
@@ -26,7 +24,6 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@@ -178,23 +175,6 @@ 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,
@@ -239,16 +219,6 @@ 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"> => {
@@ -324,23 +294,3 @@ 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]);
+2 -17
View File
@@ -12,6 +12,7 @@ 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";
@@ -100,22 +101,11 @@ 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?: MagicGenerationData }; customData?: { generationData?: MagicCacheData };
}>; }>;
export type ExcalidrawIframeLikeElement = export type ExcalidrawIframeLikeElement =
@@ -170,11 +160,6 @@ 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
+35 -37
View File
@@ -1,19 +1,20 @@
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(codePoints: ReadonlySet<number>): Promise<string>; getContent(codePoints: ReadonlySet<number>): Promise<string>;
} }
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${ export const UNPKG_PROD_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)
}/dist/prod/`; }/dist/prod/`;
/** caches for lazy loaded chunks, reused across concurrent calls and separate editor instances */
let fontEditorCache: Promise<typeof import("fonteditor-core")> | null = null;
let brotliCache: Promise<typeof import("fonteditor-core").woff2> | null = null;
export class ExcalidrawFont implements Font { export class ExcalidrawFont implements Font {
public readonly urls: URL[]; public readonly urls: URL[];
public readonly fontFace: FontFace; public readonly fontFace: FontFace;
@@ -34,7 +35,7 @@ export class ExcalidrawFont implements Font {
} }
/** /**
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks). * Tries to fetch woff2 content, based on the registered urls.
* *
* NOTE: assumes usage of `dataurl` outside the browser environment * NOTE: assumes usage of `dataurl` outside the browser environment
* *
@@ -47,7 +48,7 @@ export class ExcalidrawFont implements Font {
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 // it's dataurl, the font is inlined as base64, no need to fetch
if (url.protocol === "data:") { if (url.protocol === "data:") {
const arrayBuffer = Buffer.from( const arrayBuffer = Buffer.from(
url.toString().split(",")[1], url.toString().split(",")[1],
@@ -75,7 +76,6 @@ export class ExcalidrawFont implements Font {
arrayBuffer, arrayBuffer,
codePoints, codePoints,
); );
return base64; return base64;
} }
@@ -103,45 +103,42 @@ export class ExcalidrawFont implements Font {
} }
/** /**
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl. * Converts a font data as arraybuffer into a dataurl (base64) with subsetted glyphs based on the specified `codePoints`.
*
* NOTE: only glyphs are subsetted, other metadata as GPOS tables stay, consider filtering those as well in the future
* *
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well * @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs * @param codePoints codepoints used to subset the glyphs
* *
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl * @returns font with subsetted glyphs converted into a dataurl
*/ */
private static async subsetGlyphsByCodePoints( private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>, codePoints: ReadonlySet<number>,
): Promise<string> { ): Promise<string> {
try { // checks for the cache first to avoid triggering the import multiple times in case of concurrent calls
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers if (!fontEditorCache) {
const { compress, decompress } = await loadWoff2(); fontEditorCache = import("fonteditor-core");
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}`; const { Font, woff2 } = await fontEditorCache;
// checks for the cache first to avoid triggering the init multiple times in case of concurrent calls
if (!brotliCache) {
brotliCache = woff2.init("/wasm/woff2.wasm");
}
await brotliCache;
const font = Font.create(arrayBuffer, {
type: "woff2",
kerning: true,
hinting: true,
// subset the glyhs based on the specified codepoints!
subset: [...codePoints],
});
return font.toBase64({ type: "woff2", hinting: true });
} }
private static createUrls(uri: string): URL[] { private static createUrls(uri: string): URL[] {
@@ -173,14 +170,15 @@ export class ExcalidrawFont implements Font {
} }
// fallback url for bundled fonts // fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL)); urls.push(new URL(assetUrl, UNPKG_PROD_URL));
return urls; return urls;
} }
private static getFormat(url: URL) { private static getFormat(url: URL) {
try { try {
const parts = new URL(url).pathname.split("."); const pathname = new URL(url).pathname;
const parts = pathname.split(".");
if (parts.length === 1) { if (parts.length === 1) {
return ""; return "";
@@ -1,202 +0,0 @@
/**
* 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,
};
@@ -1,58 +0,0 @@
/**
* 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
@@ -1,70 +0,0 @@
/**
* 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
@@ -1,27 +0,0 @@
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,7 +18,6 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
}, },
files: null, files: null,
renderEmbeddables: false, renderEmbeddables: false,
skipInliningFonts: true,
}); });
}; };
@@ -41,7 +40,6 @@ 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,7 +213,6 @@ 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 {
@@ -272,7 +271,6 @@ 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";
@@ -288,6 +286,3 @@ export {
isElementInsideBBox, isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox, elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/withinBounds"; } from "../utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
+8 -10
View File
@@ -168,7 +168,6 @@
"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",
@@ -273,7 +272,8 @@
"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,7 +316,6 @@
"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.",
@@ -367,8 +366,6 @@
"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",
@@ -462,15 +459,16 @@
}, },
"stats": { "stats": {
"angle": "Angle", "angle": "Angle",
"shapes": "Shapes", "element": "Element",
"elements": "Elements",
"height": "Height", "height": "Height",
"scene": "Scene", "scene": "Scene",
"selected": "Selected", "selected": "Selected",
"storage": "Storage", "storage": "Storage",
"fullTitle": "Canvas & Shape properties", "fullTitle": "Stats & Element properties",
"title": "Properties", "title": "Stats",
"generalStats": "General", "generalStats": "General stats",
"elementProperties": "Shape properties", "elementProperties": "Element properties",
"total": "Total", "total": "Total",
"version": "Version", "version": "Version",
"versionCopy": "Click to copy", "versionCopy": "Click to copy",
+2 -12
View File
@@ -1,9 +1,4 @@
import type { import type { NormalizedZoomValue, Point, Zoom } from "./types";
NormalizedZoomValue,
NullableGridSize,
Point,
Zoom,
} from "./types";
import { import {
DEFAULT_ADAPTIVE_RADIUS, DEFAULT_ADAPTIVE_RADIUS,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
@@ -280,7 +275,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: NullableGridSize, gridSize: number | null,
): [number, number] => { ): [number, number] => {
if (gridSize) { if (gridSize) {
return [ return [
@@ -708,8 +703,3 @@ 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;
};
+1 -2
View File
@@ -67,6 +67,7 @@
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"fonteditor-core": "2.4.1",
"fractional-indexing": "3.2.0", "fractional-indexing": "3.2.0",
"fuzzy": "0.1.3", "fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
@@ -113,8 +114,6 @@
"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,11 +680,8 @@ const _renderInteractiveScene = ({
} }
} }
if ( if (appState.editingElement && isTextElement(appState.editingElement)) {
appState.editingTextElement && const textElement = allElementsMap.get(appState.editingElement.id) as
isTextElement(appState.editingTextElement)
) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
if (textElement && !textElement.autoResize) { if (textElement && !textElement.autoResize) {
@@ -897,7 +894,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.editingTextElement) !isTextElement(appState.editingElement)
) { ) {
renderTransformHandles( renderTransformHandles(
context, context,
+1 -21
View File
@@ -35,7 +35,6 @@ import type {
Zoom, Zoom,
InteractiveCanvasAppState, InteractiveCanvasAppState,
ElementsPendingErasure, ElementsPendingErasure,
PendingExcalidrawElements,
} from "../types"; } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
@@ -105,7 +104,6 @@ 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)
@@ -115,7 +113,6 @@ 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;
@@ -199,7 +196,7 @@ const generateElementCanvas = (
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas | null => { ): ExcalidrawElementWithCanvas => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
const padding = getCanvasPadding(element); const padding = getCanvasPadding(element);
@@ -210,10 +207,6 @@ const generateElementCanvas = (
zoom, zoom,
); );
if (!width || !height) {
return null;
}
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
@@ -544,10 +537,6 @@ const generateElementWithCanvas = (
appState, appState,
); );
if (!elementWithCanvas) {
return null;
}
elementWithCanvasCache.set(element, elementWithCanvas); elementWithCanvasCache.set(element, elementWithCanvas);
return elementWithCanvas; return elementWithCanvas;
@@ -683,7 +672,6 @@ export const renderElement = (
element, element,
getContainingFrame(element, elementsMap), getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure, renderConfig.elementsPendingErasure,
renderConfig.pendingFlowchartNodes,
); );
switch (element.type) { switch (element.type) {
@@ -750,10 +738,6 @@ export const renderElement = (
renderConfig, renderConfig,
appState, appState,
); );
if (!elementWithCanvas) {
return;
}
drawElementFromCanvas( drawElementFromCanvas(
elementWithCanvas, elementWithCanvas,
context, context,
@@ -893,10 +877,6 @@ export const renderElement = (
appState, appState,
); );
if (!elementWithCanvas) {
return;
}
const currentImageSmoothingStatus = context.imageSmoothingEnabled; const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if ( if (
@@ -1,66 +0,0 @@
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);
};
+22 -64
View File
@@ -31,77 +31,53 @@ 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 offsetX = (scrollX % gridSize) - gridSize; const BOLD_LINE_FREQUENCY = 5;
const offsetY = (scrollY % gridSize) - gridSize;
const actualGridSize = gridSize * zoom.value; enum GridLineColor {
Bold = "#cccccc",
const spaceWidth = 1 / zoom.value; Regular = "#e5e5e5",
context.save();
// 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 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 lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.save();
context.lineWidth = lineWidth;
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 =
gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0; Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * 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, Math.ceil(offsetY + height + gridSize * 2)); context.lineTo(x, 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 =
gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0; Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * 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(Math.ceil(offsetX + width + gridSize * 2), y); context.lineTo(offsetX + width + gridSize * 2, y);
context.stroke(); context.stroke();
} }
context.restore(); context.restore();
@@ -223,11 +199,10 @@ const _renderStaticScene = ({
context.scale(appState.zoom.value, appState.zoom.value); context.scale(appState.zoom.value, appState.zoom.value);
// Grid // Grid
if (renderGrid) { if (renderGrid && appState.gridSize) {
strokeGrid( strokeGrid(
context, context,
appState.gridSize, appState.gridSize,
appState.gridStep,
appState.scrollX, appState.scrollX,
appState.scrollY, appState.scrollY,
appState.zoom, appState.zoom,
@@ -395,23 +370,6 @@ 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 */
+8 -20
View File
@@ -1,7 +1,6 @@
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";
@@ -65,13 +64,11 @@ export class Renderer {
const getRenderableElements = ({ const getRenderableElements = ({
elements, elements,
editingTextElement, editingElement,
newElementId,
pendingImageElementId, pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"]; editingElement: AppState["editingElement"];
newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
@@ -86,16 +83,12 @@ 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 (
!editingTextElement || !editingElement ||
editingTextElement.type !== "text" || editingElement.type !== "text" ||
element.id !== editingTextElement.id element.id !== editingElement.id
) { ) {
elementsMap.set(element.id, element); elementsMap.set(element.id, element);
} }
@@ -112,8 +105,7 @@ export class Renderer {
scrollY, scrollY,
height, height,
width, width,
editingTextElement, editingElement,
newElementId,
pendingImageElementId, pendingImageElementId,
// cache-invalidation nonce // cache-invalidation nonce
sceneNonce: _sceneNonce, sceneNonce: _sceneNonce,
@@ -125,10 +117,7 @@ export class Renderer {
scrollY: AppState["scrollY"]; scrollY: AppState["scrollY"];
height: AppState["height"]; height: AppState["height"];
width: AppState["width"]; width: AppState["width"];
editingTextElement: AppState["editingTextElement"]; editingElement: AppState["editingElement"];
/** 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"]>;
}) => { }) => {
@@ -136,8 +125,7 @@ export class Renderer {
const elementsMap = getRenderableElements({ const elementsMap = getRenderableElements({
elements, elements,
editingTextElement, editingElement,
newElementId,
pendingImageElementId, pendingImageElementId,
}); });

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