Compare commits

...

33 Commits

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

* fix: prevent zooming browser over DOM

---------

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

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