Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 385416d231 | |||
| 4d04e3e9a1 | |||
| e08ebdec28 | |||
| 8d8f696628 | |||
| 19b3dc658a | |||
| 4e0441eeb4 | |||
| 8013eb5e16 | |||
| 725412ebd3 | |||
| 7da176ff7d | |||
| 5fffc4743f | |||
| 8608d7b2e0 | |||
| 19b03b4ca9 | |||
| 416e8b3e42 | |||
| 98e0cd9078 | |||
| f3c16a600d | |||
| 835eb8d2fd | |||
| fde796a7a0 | |||
| 7c41944856 | |||
| f1b097ad06 | |||
| 9fcbbe0d27 | |||
| ec070911b8 | |||
| dcdeb2be57 | |||
| a8acc8212d | |||
| a89a03c66c | |||
| e32836f799 | |||
| 06c40006db | |||
| 91c7748c3d | |||
| f738b74791 | |||
| 00ae455873 | |||
| 06c5ea94d3 | |||
| f55ecb96cc | |||
| a6a32b9b29 | |||
| ac0d3059dc | |||
| 1161f1b8ba | |||
| 204e06b77b | |||
| 414182f599 | |||
| b9d27d308e | |||
| 3bdaafe4b5 | |||
| ae89608985 | |||
| 3085f4af81 | |||
| 531f3e5524 | |||
| 90ec2739ae | |||
| f29e9df72d | |||
| b5ad7ae4e3 | |||
| c78e4aab7f | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe | |||
| 86605829c6 | |||
| c398af6c92 | |||
| 973f2a464d | |||
| 02cef5ea92 | |||
| d615c2cea1 | |||
| 446f871536 | |||
| 34bff557e3 | |||
| a0e54e3768 | |||
| d6ec1dc7e6 | |||
| 62e20aa247 | |||
| 0199c82e98 | |||
| 3c07ff358a | |||
| d9c85ff18f | |||
| 6d84fa21c5 | |||
| 5666fd8199 | |||
| abdacf8239 | |||
| 1068153b25 | |||
| 09876aba6d | |||
| 8ceb55dd02 | |||
| b1f3cc50ee | |||
| c72c47f0cd | |||
| 37b75263f8 | |||
| c08840358b | |||
| e99baaa6bb | |||
| a8857f2849 | |||
| df1f9281b4 | |||
| c210b7b092 | |||
| 660d21fe46 | |||
| c7780cb9cb | |||
| 4e265629c3 | |||
| 1c611d6c4f | |||
| ab6af41d33 | |||
| 15dfe0cc7c |
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build:
|
||||||
|
|||||||
+10
-8
@@ -20,7 +20,6 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
TITLE_TIMEOUT,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -120,6 +119,7 @@ import {
|
|||||||
LibraryIndexedDBAdapter,
|
LibraryIndexedDBAdapter,
|
||||||
LibraryLocalStorageMigrationAdapter,
|
LibraryLocalStorageMigrationAdapter,
|
||||||
LocalData,
|
LocalData,
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
} from "./data/LocalData";
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
@@ -499,11 +499,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleTimeout = setTimeout(
|
|
||||||
() => (document.title = APP_NAME),
|
|
||||||
TITLE_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@@ -594,7 +589,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
clearTimeout(titleTimeout);
|
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
@@ -734,6 +728,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
|
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||||
[setShareDialogState],
|
[setShareDialogState],
|
||||||
@@ -908,10 +904,15 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="alertalert--warning">
|
||||||
{t("alerts.collabOfflineWarning")}
|
{t("alerts.collabOfflineWarning")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{localStorageQuotaExceeded && (
|
||||||
|
<div className="alert alert--danger">
|
||||||
|
{t("alerts.localStorageQuotaExceeded")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{latestShareableLink && (
|
{latestShareableLink && (
|
||||||
<ShareableLinkDialog
|
<ShareableLinkDialog
|
||||||
link={latestShareableLink}
|
link={latestShareableLink}
|
||||||
@@ -1142,6 +1143,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
ref={debugCanvasRef}
|
ref={debugCanvasRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* <FreedrawDebugSliders /> */}
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
|||||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||||
|
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
|||||||
@@ -530,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingRoomLinkData) {
|
if (existingRoomLinkData) {
|
||||||
|
// when joining existing room, don't merge it with current scene data
|
||||||
|
this.excalidrawAPI.resetScene();
|
||||||
|
} else {
|
||||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||||
if (isImageElement(element) && element.status === "saved") {
|
if (isImageElement(element) && element.status === "saved") {
|
||||||
return newElementWith(element, { status: "pending" });
|
return newElementWith(element, { status: "pending" });
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { STROKE_OPTIONS, isFreeDrawElement } from "@excalidraw/element";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||||
|
import { useExcalidrawElements } from "@excalidraw/excalidraw/components/App";
|
||||||
|
|
||||||
|
import { round } from "../../packages/math/src";
|
||||||
|
|
||||||
|
export const FreedrawDebugSliders = () => {
|
||||||
|
const [streamline, setStreamline] = useState<number>(
|
||||||
|
STROKE_OPTIONS.default.streamline,
|
||||||
|
);
|
||||||
|
const [simplify, setSimplify] = useState<number>(
|
||||||
|
STROKE_OPTIONS.default.simplify,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.h) {
|
||||||
|
window.h = {} as any;
|
||||||
|
}
|
||||||
|
if (!window.h.debugFreedraw) {
|
||||||
|
window.h.debugFreedraw = {
|
||||||
|
enabled: true,
|
||||||
|
...STROKE_OPTIONS.default,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setStreamline(window.h.debugFreedraw.streamline);
|
||||||
|
setSimplify(window.h.debugFreedraw.simplify);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStreamlineChange = (value: number) => {
|
||||||
|
setStreamline(value);
|
||||||
|
if (window.h && window.h.debugFreedraw) {
|
||||||
|
window.h.debugFreedraw.streamline = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSimplifyChange = (value: number) => {
|
||||||
|
setSimplify(value);
|
||||||
|
if (window.h && window.h.debugFreedraw) {
|
||||||
|
window.h.debugFreedraw.simplify = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState<boolean>(
|
||||||
|
window.h?.debugFreedraw?.enabled ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// counter incrasing each 50ms
|
||||||
|
const [, setCounter] = useState<number>(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCounter((prev) => prev + 1);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const elements = useExcalidrawElements();
|
||||||
|
const appState = useUIAppState();
|
||||||
|
|
||||||
|
const newFreedrawElement =
|
||||||
|
appState.newElement && isFreeDrawElement(appState.newElement)
|
||||||
|
? appState.newElement
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "70px",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{newFreedrawElement && (
|
||||||
|
<div>
|
||||||
|
pressures:{" "}
|
||||||
|
{newFreedrawElement.simulatePressure
|
||||||
|
? "simulated"
|
||||||
|
: JSON.stringify(
|
||||||
|
newFreedrawElement.pressures
|
||||||
|
.slice(-4)
|
||||||
|
.map((x) => round(x, 2))
|
||||||
|
.join(" ") || [],
|
||||||
|
)}{" "}
|
||||||
|
({round(window.__lastPressure__ || 0, 2) || "?"})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
{" "}
|
||||||
|
enabled
|
||||||
|
<br />
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (window.h.debugFreedraw) {
|
||||||
|
window.h.debugFreedraw.enabled = e.target.checked;
|
||||||
|
setEnabled(e.target.checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
Streamline: {streamline.toFixed(2)}
|
||||||
|
<br />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={streamline}
|
||||||
|
onChange={(e) => handleStreamlineChange(parseFloat(e.target.value))}
|
||||||
|
style={{ width: "150px" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
Simplify: {simplify.toFixed(2)}
|
||||||
|
<br />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={simplify}
|
||||||
|
onChange={(e) => handleSimplifyChange(parseFloat(e.target.value))}
|
||||||
|
style={{ width: "150px" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
|
|
||||||
|
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||||
|
|
||||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||||
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
|
|||||||
|
|
||||||
const filesStore = createStore("files-db", "files-store");
|
const filesStore = createStore("files-db", "files-store");
|
||||||
|
|
||||||
|
export const localStorageQuotaExceededAtom = atom(false);
|
||||||
|
|
||||||
class LocalFileManager extends FileManager {
|
class LocalFileManager extends FileManager {
|
||||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||||
await entries(filesStore).then((entries) => {
|
await entries(filesStore).then((entries) => {
|
||||||
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
|
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||||
|
localStorageQuotaExceededAtom,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const _appState = clearAppStateForLocalStorage(appState);
|
const _appState = clearAppStateForLocalStorage(appState);
|
||||||
|
|
||||||
@@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = (
|
|||||||
JSON.stringify(_appState),
|
JSON.stringify(_appState),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
|
if (localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Unable to access window.localStorage
|
// Unable to access window.localStorage
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
|
||||||
|
appJotaiStore.set(localStorageQuotaExceededAtom, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isQuotaExceededError = (error: any) => {
|
||||||
|
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||||
|
};
|
||||||
|
|
||||||
type SavingLockTypes = "collaboration";
|
type SavingLockTypes = "collaboration";
|
||||||
|
|
||||||
export class LocalData {
|
export class LocalData {
|
||||||
|
|||||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{ repairBindings: true, refreshDimensions: false },
|
{
|
||||||
|
repairBindings: true,
|
||||||
|
refreshDimensions: false,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
|
deleteInvisibleElements: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
<title>Excalidraw Whiteboard</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.collab-offline-warning {
|
.alert {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6.5rem;
|
top: 6.5rem;
|
||||||
@@ -69,10 +69,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: var(--color-text-warning);
|
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: var(--color-text-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: var(--color-danger-dark);
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { defaultLang } from "@excalidraw/excalidraw/i18n";
|
|
||||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|
||||||
import {
|
|
||||||
screen,
|
|
||||||
fireEvent,
|
|
||||||
waitFor,
|
|
||||||
render,
|
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
|
||||||
|
|
||||||
describe("Test LanguageList", () => {
|
|
||||||
it("rerenders UI on language change", async () => {
|
|
||||||
await render(<ExcalidrawApp />);
|
|
||||||
|
|
||||||
// select rectangle tool to show properties menu
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
// english lang should display `thin` label
|
|
||||||
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
|
|
||||||
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
|
|
||||||
|
|
||||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
|
||||||
target: { value: "de-DE" },
|
|
||||||
});
|
|
||||||
// switching to german, `thin` label should no longer exist
|
|
||||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
|
|
||||||
// reset language
|
|
||||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
|
||||||
target: { value: defaultLang.code },
|
|
||||||
});
|
|
||||||
// switching back to English
|
|
||||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
|
|||||||
},
|
},
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
"viewport": {
|
"viewport": {
|
||||||
"isLandscape": false,
|
"isLandscape": true,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
|||||||
|
|
||||||
sinkDown(idx: number) {
|
sinkDown(idx: number) {
|
||||||
const node = this.content[idx];
|
const node = this.content[idx];
|
||||||
|
const nodeScore = this.scoreFunction(node);
|
||||||
while (idx > 0) {
|
while (idx > 0) {
|
||||||
const parentN = ((idx + 1) >> 1) - 1;
|
const parentN = ((idx + 1) >> 1) - 1;
|
||||||
const parent = this.content[parentN];
|
const parent = this.content[parentN];
|
||||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
if (nodeScore < this.scoreFunction(parent)) {
|
||||||
this.content[parentN] = node;
|
|
||||||
this.content[idx] = parent;
|
this.content[idx] = parent;
|
||||||
idx = parentN; // TODO: Optimize
|
idx = parentN; // TODO: Optimize
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
bubbleUp(idx: number) {
|
bubbleUp(idx: number) {
|
||||||
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
|
|||||||
const score = this.scoreFunction(node);
|
const score = this.scoreFunction(node);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const child2N = (idx + 1) << 1;
|
const child1N = ((idx + 1) << 1) - 1;
|
||||||
const child1N = child2N - 1;
|
const child2N = child1N + 1;
|
||||||
let swap = null;
|
let smallestIdx = idx;
|
||||||
let child1Score = 0;
|
let smallestScore = score;
|
||||||
|
|
||||||
|
// Check left child
|
||||||
if (child1N < length) {
|
if (child1N < length) {
|
||||||
const child1 = this.content[child1N];
|
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||||
child1Score = this.scoreFunction(child1);
|
if (child1Score < smallestScore) {
|
||||||
if (child1Score < score) {
|
smallestIdx = child1N;
|
||||||
swap = child1N;
|
smallestScore = child1Score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check right child
|
||||||
if (child2N < length) {
|
if (child2N < length) {
|
||||||
const child2 = this.content[child2N];
|
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||||
const child2Score = this.scoreFunction(child2);
|
if (child2Score < smallestScore) {
|
||||||
if (child2Score < (swap === null ? score : child1Score)) {
|
smallestIdx = child2N;
|
||||||
swap = child2N;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (swap !== null) {
|
if (smallestIdx === idx) {
|
||||||
this.content[idx] = this.content[swap];
|
|
||||||
this.content[swap] = node;
|
|
||||||
idx = swap; // TODO: Optimize
|
|
||||||
} else {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move the smaller child up, continue finding position for node
|
||||||
|
this.content[idx] = this.content[smallestIdx];
|
||||||
|
idx = smallestIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Place node in its final position
|
||||||
|
this.content[idx] = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
push(node: T) {
|
push(node: T) {
|
||||||
|
|||||||
@@ -18,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|||||||
export const isSafari =
|
export const isSafari =
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
export const isIOS =
|
export const isIOS =
|
||||||
/iPad|iPhone/.test(navigator.platform) ||
|
/iPad|iPhone/i.test(navigator.platform) ||
|
||||||
// iPadOS 13+
|
// iPadOS 13+
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
// keeping function so it can be mocked in test
|
// keeping function so it can be mocked in test
|
||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||||
|
|
||||||
|
export const isMobile =
|
||||||
|
isIOS ||
|
||||||
|
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||||
|
navigator.userAgent,
|
||||||
|
) ||
|
||||||
|
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||||
|
|
||||||
export const supportsResizeObserver =
|
export const supportsResizeObserver =
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
@@ -118,10 +125,12 @@ export const ENV = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
|
SIDEBAR: "sidebar",
|
||||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
|
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@@ -252,13 +261,20 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const STRING_MIME_TYPES = {
|
||||||
text: "text/plain",
|
text: "text/plain",
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
|
// LEGACY: fully-qualified library JSON data
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
// list of excalidraw library item ids
|
||||||
|
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
...STRING_MIME_TYPES,
|
||||||
// image-encoded excalidraw data
|
// image-encoded excalidraw data
|
||||||
"excalidraw.svg": "image/svg+xml",
|
"excalidraw.svg": "image/svg+xml",
|
||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
@@ -335,10 +351,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
|
|
||||||
// breakpoints
|
// breakpoints
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
// mobile: up to 699px
|
||||||
|
export const MQ_MAX_MOBILE = 599;
|
||||||
|
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
|
|
||||||
|
// tablets
|
||||||
|
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||||
|
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||||
|
|
||||||
|
// desktop/laptop
|
||||||
|
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||||
|
|
||||||
// sidebar
|
// sidebar
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -416,8 +442,9 @@ export const ROUGHNESS = {
|
|||||||
|
|
||||||
export const STROKE_WIDTH = {
|
export const STROKE_WIDTH = {
|
||||||
thin: 1,
|
thin: 1,
|
||||||
bold: 2,
|
medium: 2,
|
||||||
extraBold: 4,
|
bold: 4,
|
||||||
|
extraBold: 8,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_ELEMENT_PROPS: {
|
export const DEFAULT_ELEMENT_PROPS: {
|
||||||
@@ -433,7 +460,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||||||
strokeColor: COLOR_PALETTE.black,
|
strokeColor: COLOR_PALETTE.black,
|
||||||
backgroundColor: COLOR_PALETTE.transparent,
|
backgroundColor: COLOR_PALETTE.transparent,
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
strokeWidth: 2,
|
strokeWidth: STROKE_WIDTH.medium,
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
roughness: ROUGHNESS.artist,
|
roughness: ROUGHNESS.artist,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
@@ -515,3 +542,10 @@ export enum UserIdleState {
|
|||||||
* the start and end points)
|
* the start and end points)
|
||||||
*/
|
*/
|
||||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
|
||||||
|
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||||
|
|
||||||
|
// glass background for mobile action buttons
|
||||||
|
export const MOBILE_ACTION_BUTTON_BG = {
|
||||||
|
background: "var(--mobile-action-button-bg)",
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
ENV,
|
ENV,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
isDarwin,
|
isAndroid,
|
||||||
|
isIOS,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@@ -91,7 +92,8 @@ export const isWritableElement = (
|
|||||||
(target instanceof HTMLInputElement &&
|
(target instanceof HTMLInputElement &&
|
||||||
(target.type === "text" ||
|
(target.type === "text" ||
|
||||||
target.type === "number" ||
|
target.type === "number" ||
|
||||||
target.type === "password"));
|
target.type === "password" ||
|
||||||
|
target.type === "search"));
|
||||||
|
|
||||||
export const getFontFamilyString = ({
|
export const getFontFamilyString = ({
|
||||||
fontFamily,
|
fontFamily,
|
||||||
@@ -119,6 +121,11 @@ export const getFontString = ({
|
|||||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** executes callback in the frame that's after the current one */
|
||||||
|
export const nextAnimationFrame = async (cb: () => any) => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||||
|
};
|
||||||
|
|
||||||
export const debounce = <T extends any[]>(
|
export const debounce = <T extends any[]>(
|
||||||
fn: (...args: T) => void,
|
fn: (...args: T) => void,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
@@ -418,19 +425,6 @@ export const allowFullScreen = () =>
|
|||||||
|
|
||||||
export const exitFullScreen = () => document.exitFullscreen();
|
export const exitFullScreen = () => document.exitFullscreen();
|
||||||
|
|
||||||
export const getShortcutKey = (shortcut: string): string => {
|
|
||||||
shortcut = shortcut
|
|
||||||
.replace(/\bAlt\b/i, "Alt")
|
|
||||||
.replace(/\bShift\b/i, "Shift")
|
|
||||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
|
||||||
if (isDarwin) {
|
|
||||||
return shortcut
|
|
||||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
|
||||||
.replace(/\bAlt\b/i, "Option");
|
|
||||||
}
|
|
||||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewportCoordsToSceneCoords = (
|
export const viewportCoordsToSceneCoords = (
|
||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
{
|
{
|
||||||
@@ -1278,3 +1272,59 @@ export const reduceToCommonValue = <T, R = T>(
|
|||||||
|
|
||||||
return commonValue;
|
return commonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isMobileOrTablet = (): boolean => {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
const platform = navigator.platform || "";
|
||||||
|
const uaData = (navigator as any).userAgentData as
|
||||||
|
| { mobile?: boolean; platform?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||||
|
if (uaData) {
|
||||||
|
const plat = (uaData.platform || "").toLowerCase();
|
||||||
|
const isDesktopOS =
|
||||||
|
plat === "windows" ||
|
||||||
|
plat === "macos" ||
|
||||||
|
plat === "linux" ||
|
||||||
|
plat === "chrome os";
|
||||||
|
if (uaData.mobile === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (uaData.mobile === false && plat === "android") {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
if (isDesktopOS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) ios (includes ipad) --------------------------------------------
|
||||||
|
if (isIOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3) android legacy ua fallback -------------------------------------
|
||||||
|
if (isAndroid) {
|
||||||
|
const isAndroidPhone = /Mobile/i.test(ua);
|
||||||
|
const isAndroidTablet = !isAndroidPhone;
|
||||||
|
if (isAndroidPhone || isAndroidTablet) {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4) last resort desktop exclusion ----------------------------------
|
||||||
|
const looksDesktopPlatform =
|
||||||
|
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||||
|
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||||
|
if (looksDesktopPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|||||||
@@ -164,9 +164,14 @@ export class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(elements: ElementsMapOrArray | null = null) {
|
constructor(
|
||||||
|
elements: ElementsMapOrArray | null = null,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (elements) {
|
if (elements) {
|
||||||
this.replaceAllElements(elements);
|
this.replaceAllElements(elements, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,12 +268,19 @@ export class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(
|
||||||
|
nextElements: ElementsMapOrArray,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
const _nextElements = toArray(nextElements);
|
const _nextElements = toArray(nextElements);
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
validateIndicesThrottled(_nextElements);
|
if (!options?.skipValidation) {
|
||||||
|
validateIndicesThrottled(_nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(_nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
|||||||
@@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
intersector,
|
intersector,
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
).sort(pointDistanceSq)[0];
|
).sort(pointDistanceSq)[0];
|
||||||
|
|
||||||
|
if (!intersection) {
|
||||||
|
const anotherPoint = pointFrom<GlobalPoint>(
|
||||||
|
!isHorizontal ? center[0] : snapPoint[0],
|
||||||
|
isHorizontal ? center[1] : snapPoint[1],
|
||||||
|
);
|
||||||
|
const anotherIntersector = lineSegment(
|
||||||
|
anotherPoint,
|
||||||
|
pointFromVector(
|
||||||
|
vectorScale(
|
||||||
|
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
|
||||||
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
|
),
|
||||||
|
anotherPoint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
intersection = intersectElementWithLineSegment(
|
||||||
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
|
anotherIntersector,
|
||||||
|
FIXED_BINDING_DISTANCE,
|
||||||
|
).sort(pointDistanceSq)[0];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
intersection = intersectElementWithLineSegment(
|
intersection = intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
rescalePoints,
|
rescalePoints,
|
||||||
sizeOf,
|
sizeOf,
|
||||||
|
STROKE_WIDTH,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
@@ -321,19 +323,42 @@ export const getElementLineSegments = (
|
|||||||
|
|
||||||
if (shape.type === "polycurve") {
|
if (shape.type === "polycurve") {
|
||||||
const curves = shape.data;
|
const curves = shape.data;
|
||||||
const points = curves
|
const pointsOnCurves = curves.map((curve) =>
|
||||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
pointsOnBezierCurves(curve, 10),
|
||||||
.flat();
|
);
|
||||||
let i = 0;
|
|
||||||
const segments: LineSegment<GlobalPoint>[] = [];
|
const segments: LineSegment<GlobalPoint>[] = [];
|
||||||
while (i < points.length - 1) {
|
|
||||||
segments.push(
|
if (
|
||||||
lineSegment(
|
(isLineElement(element) && !element.polygon) ||
|
||||||
pointFrom(points[i][0], points[i][1]),
|
isArrowElement(element)
|
||||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
) {
|
||||||
),
|
for (const points of pointsOnCurves) {
|
||||||
);
|
let i = 0;
|
||||||
i++;
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const points = pointsOnCurves.flat();
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < points.length - 1) {
|
||||||
|
segments.push(
|
||||||
|
lineSegment(
|
||||||
|
pointFrom(points[i][0], points[i][1]),
|
||||||
|
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
@@ -808,9 +833,15 @@ export const getArrowheadPoints = (
|
|||||||
// This value is selected by minimizing a minimum size with the last segment of the arrowhead
|
// This value is selected by minimizing a minimum size with the last segment of the arrowhead
|
||||||
const lengthMultiplier =
|
const lengthMultiplier =
|
||||||
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
||||||
const minSize = Math.min(size, length * lengthMultiplier);
|
// make arrowheads bigger for thick strokes
|
||||||
const xs = x2 - nx * minSize;
|
const strokeWidthMultiplier =
|
||||||
const ys = y2 - ny * minSize;
|
element.strokeWidth >= STROKE_WIDTH.extraBold ? 1.5 : 1;
|
||||||
|
|
||||||
|
const adjustedSize =
|
||||||
|
Math.min(size, length * lengthMultiplier) * strokeWidthMultiplier;
|
||||||
|
|
||||||
|
const xs = x2 - nx * adjustedSize;
|
||||||
|
const ys = y2 - ny * adjustedSize;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
arrowhead === "dot" ||
|
arrowhead === "dot" ||
|
||||||
@@ -859,7 +890,7 @@ export const getArrowheadPoints = (
|
|||||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||||
|
|
||||||
[ox, oy] = pointRotateRads(
|
[ox, oy] = pointRotateRads(
|
||||||
pointFrom(x2 + minSize * 2, y2),
|
pointFrom(x2 + adjustedSize * 2, y2),
|
||||||
pointFrom(x2, y2),
|
pointFrom(x2, y2),
|
||||||
Math.atan2(py - y2, px - x2) as Radians,
|
Math.atan2(py - y2, px - x2) as Radians,
|
||||||
);
|
);
|
||||||
@@ -870,7 +901,7 @@ export const getArrowheadPoints = (
|
|||||||
: [0, 0];
|
: [0, 0];
|
||||||
|
|
||||||
[ox, oy] = pointRotateRads(
|
[ox, oy] = pointRotateRads(
|
||||||
pointFrom(x2 - minSize * 2, y2),
|
pointFrom(x2 - adjustedSize * 2, y2),
|
||||||
pointFrom(x2, y2),
|
pointFrom(x2, y2),
|
||||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||||
);
|
);
|
||||||
@@ -1126,7 +1157,9 @@ export interface BoundingBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCommonBoundingBox = (
|
export const getCommonBoundingBox = (
|
||||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
elements:
|
||||||
|
| readonly ExcalidrawElement[]
|
||||||
|
| readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): BoundingBox => {
|
): BoundingBox => {
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
|
|||||||
type === "freedraw";
|
type === "freedraw";
|
||||||
|
|
||||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
type === "rectangle" ||
|
||||||
|
type === "ellipse" ||
|
||||||
|
type === "diamond" ||
|
||||||
|
type === "freedraw" ||
|
||||||
|
type === "arrow" ||
|
||||||
|
type === "line" ||
|
||||||
|
type === "text";
|
||||||
|
|
||||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
|
|||||||
+367
-157
@@ -2,7 +2,6 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
arrayToObject,
|
arrayToObject,
|
||||||
assertNever,
|
assertNever,
|
||||||
invariant,
|
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
@@ -56,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
|||||||
|
|
||||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
import { Scene } from "./Scene";
|
|
||||||
|
|
||||||
import { StoreSnapshot } from "./store";
|
import { StoreSnapshot } from "./store";
|
||||||
|
|
||||||
|
import { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "./binding";
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@@ -151,13 +150,27 @@ export class Delta<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges two deltas into a new one.
|
||||||
|
*/
|
||||||
|
public static merge<T>(
|
||||||
|
delta1: Delta<T>,
|
||||||
|
delta2: Delta<T>,
|
||||||
|
delta3: Delta<T> = Delta.empty(),
|
||||||
|
) {
|
||||||
|
return Delta.create(
|
||||||
|
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
||||||
|
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges deleted and inserted object partials.
|
* Merges deleted and inserted object partials.
|
||||||
*/
|
*/
|
||||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||||
prev: T,
|
prev: T,
|
||||||
added: T,
|
added: T,
|
||||||
removed: T,
|
removed: T = {} as T,
|
||||||
) {
|
) {
|
||||||
const cloned = { ...prev };
|
const cloned = { ...prev };
|
||||||
|
|
||||||
@@ -497,6 +510,11 @@ export interface DeltaContainer<T> {
|
|||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squashes the current delta with the given one.
|
||||||
|
*/
|
||||||
|
squash(delta: DeltaContainer<T>): this;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether all `Delta`s are empty.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
@@ -504,7 +522,11 @@ export interface DeltaContainer<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
|
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||||
|
return new AppStateDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
@@ -535,76 +557,137 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return new AppStateDelta(inversedDelta);
|
return new AppStateDelta(inversedDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public squash(delta: AppStateDelta): this {
|
||||||
|
if (delta.isEmpty()) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||||
|
this.delta.deleted.selectedElementIds ?? {},
|
||||||
|
delta.delta.deleted.selectedElementIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||||
|
this.delta.inserted.selectedElementIds ?? {},
|
||||||
|
delta.delta.inserted.selectedElementIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||||
|
this.delta.deleted.selectedGroupIds ?? {},
|
||||||
|
delta.delta.deleted.selectedGroupIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||||
|
this.delta.inserted.selectedGroupIds ?? {},
|
||||||
|
delta.delta.inserted.selectedGroupIds ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||||
|
this.delta.deleted.lockedMultiSelections ?? {},
|
||||||
|
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||||
|
this.delta.inserted.lockedMultiSelections ?? {},
|
||||||
|
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedInserted: Partial<ObservedAppState> = {};
|
||||||
|
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||||
|
Object.keys(mergedInsertedSelectedElementIds).length
|
||||||
|
) {
|
||||||
|
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||||
|
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||||
|
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||||
|
) {
|
||||||
|
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||||
|
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||||
|
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||||
|
) {
|
||||||
|
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||||
|
mergedInserted.lockedMultiSelections =
|
||||||
|
mergedInsertedLockedMultiSelections;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.delta = Delta.merge(
|
||||||
|
this.delta,
|
||||||
|
delta.delta,
|
||||||
|
Delta.create(mergedDeleted, mergedInserted),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
): [AppState, boolean] {
|
): [AppState, boolean] {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
selectedElementIds: removedSelectedElementIds = {},
|
selectedElementIds: deletedSelectedElementIds = {},
|
||||||
selectedGroupIds: removedSelectedGroupIds = {},
|
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||||
|
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||||
} = this.delta.deleted;
|
} = this.delta.deleted;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedElementIds: addedSelectedElementIds = {},
|
selectedElementIds: insertedSelectedElementIds = {},
|
||||||
selectedGroupIds: addedSelectedGroupIds = {},
|
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||||
selectedLinearElementId,
|
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||||
selectedLinearElementIsEditing,
|
selectedLinearElement: insertedSelectedLinearElement,
|
||||||
...directlyApplicablePartial
|
...directlyApplicablePartial
|
||||||
} = this.delta.inserted;
|
} = this.delta.inserted;
|
||||||
|
|
||||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||||
appState.selectedElementIds,
|
appState.selectedElementIds,
|
||||||
addedSelectedElementIds,
|
insertedSelectedElementIds,
|
||||||
removedSelectedElementIds,
|
deletedSelectedElementIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
addedSelectedGroupIds,
|
insertedSelectedGroupIds,
|
||||||
removedSelectedGroupIds,
|
deletedSelectedGroupIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
let selectedLinearElement = appState.selectedLinearElement;
|
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||||
|
appState.lockedMultiSelections,
|
||||||
|
insertedLockedMultiSelections,
|
||||||
|
deletedLockedMultiSelections,
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedLinearElementId === null) {
|
const selectedLinearElement =
|
||||||
// Unselect linear element (visible change)
|
insertedSelectedLinearElement &&
|
||||||
selectedLinearElement = null;
|
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||||
} else if (
|
? new LinearElementEditor(
|
||||||
selectedLinearElementId &&
|
nextElements.get(
|
||||||
nextElements.has(selectedLinearElementId)
|
insertedSelectedLinearElement.elementId,
|
||||||
) {
|
) as NonDeleted<ExcalidrawLinearElement>,
|
||||||
selectedLinearElement = new LinearElementEditor(
|
nextElements,
|
||||||
nextElements.get(
|
insertedSelectedLinearElement.isEditing,
|
||||||
selectedLinearElementId,
|
)
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
: null;
|
||||||
nextElements,
|
|
||||||
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
// Value being 'null' is equivaluent to unknown in this case because it only gets set
|
|
||||||
// to null when 'selectedLinearElementId' is set to null
|
|
||||||
selectedLinearElementIsEditing != null
|
|
||||||
) {
|
|
||||||
invariant(
|
|
||||||
selectedLinearElement,
|
|
||||||
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
selectedLinearElement = {
|
|
||||||
...selectedLinearElement,
|
|
||||||
isEditing: selectedLinearElementIsEditing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextAppState = {
|
const nextAppState = {
|
||||||
...appState,
|
...appState,
|
||||||
...directlyApplicablePartial,
|
...directlyApplicablePartial,
|
||||||
selectedElementIds: mergedSelectedElementIds,
|
selectedElementIds: mergedSelectedElementIds,
|
||||||
selectedGroupIds: mergedSelectedGroupIds,
|
selectedGroupIds: mergedSelectedGroupIds,
|
||||||
selectedLinearElement,
|
lockedMultiSelections: mergedLockedMultiSelections,
|
||||||
|
selectedLinearElement:
|
||||||
|
typeof insertedSelectedLinearElement !== "undefined"
|
||||||
|
? selectedLinearElement
|
||||||
|
: appState.selectedLinearElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||||
@@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedLinearElementId": {
|
case "selectedLinearElement":
|
||||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
const nextLinearElement = nextAppState[key];
|
||||||
const linearElement = nextAppState[appStateKey];
|
|
||||||
|
|
||||||
if (!linearElement) {
|
if (!nextLinearElement) {
|
||||||
// previously there was a linear element (assuming visible), now there is none
|
// previously there was a linear element (assuming visible), now there is none
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
const element = nextElements.get(linearElement.elementId);
|
const element = nextElements.get(nextLinearElement.elementId);
|
||||||
|
|
||||||
if (element && !element.isDeleted) {
|
if (element && !element.isDeleted) {
|
||||||
// previously there wasn't a linear element, now there is one which is visible
|
// previously there wasn't a linear element, now there is one which is visible
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
// there was assigned a linear element now, but it's deleted
|
// there was assigned a linear element now, but it's deleted
|
||||||
nextAppState[appStateKey] = null;
|
nextAppState[key] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
case "lockedMultiSelections":
|
||||||
case "selectedLinearElementIsEditing": {
|
|
||||||
// Changes in editing state are always visible
|
|
||||||
const prevIsEditing =
|
|
||||||
prevAppState.selectedLinearElement?.isEditing ?? false;
|
|
||||||
const nextIsEditing =
|
|
||||||
nextAppState.selectedLinearElement?.isEditing ?? false;
|
|
||||||
|
|
||||||
if (prevIsEditing !== nextIsEditing) {
|
|
||||||
visibleDifferenceFlag.value = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "lockedMultiSelections": {
|
|
||||||
const prevLockedUnits = prevAppState[key] || {};
|
const prevLockedUnits = prevAppState[key] || {};
|
||||||
const nextLockedUnits = nextAppState[key] || {};
|
const nextLockedUnits = nextAppState[key] || {};
|
||||||
|
|
||||||
|
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||||
|
// hence instead we should check whether elements are actually visible,
|
||||||
|
// so that once these changes are applied they actually result in a visible change to the user
|
||||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "activeLockedId":
|
||||||
case "activeLockedId": {
|
|
||||||
const prevHitLockedId = prevAppState[key] || null;
|
const prevHitLockedId = prevAppState[key] || null;
|
||||||
const nextHitLockedId = nextAppState[key] || null;
|
const nextHitLockedId = nextAppState[key] || null;
|
||||||
|
|
||||||
|
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||||
|
// hence instead we should check whether elements are actually visible,
|
||||||
|
// so that once these changes are applied they actually result in a visible change to the user
|
||||||
if (prevHitLockedId !== nextHitLockedId) {
|
if (prevHitLockedId !== nextHitLockedId) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
default:
|
||||||
default: {
|
|
||||||
assertNever(
|
assertNever(
|
||||||
key,
|
key,
|
||||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return visibleDifferenceFlag.value;
|
return visibleDifferenceFlag.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static convertToAppStateKey(
|
|
||||||
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
|
|
||||||
): keyof Pick<AppState, "selectedLinearElement"> {
|
|
||||||
switch (key) {
|
|
||||||
case "selectedLinearElementId":
|
|
||||||
return "selectedLinearElement";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static filterSelectedElements(
|
private static filterSelectedElements(
|
||||||
selectedElementIds: AppState["selectedElementIds"],
|
selectedElementIds: AppState["selectedElementIds"],
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
@@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
editingGroupId,
|
editingGroupId,
|
||||||
selectedGroupIds,
|
selectedGroupIds,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
selectedLinearElementIsEditing,
|
|
||||||
croppingElementId,
|
croppingElementId,
|
||||||
lockedMultiSelections,
|
lockedMultiSelections,
|
||||||
activeLockedId,
|
activeLockedId,
|
||||||
@@ -926,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
"lockedMultiSelections",
|
"lockedMultiSelections",
|
||||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||||
);
|
);
|
||||||
Delta.diffObjects(
|
|
||||||
deleted,
|
|
||||||
inserted,
|
|
||||||
"activeLockedId",
|
|
||||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||||
@@ -960,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
|||||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||||
|
|
||||||
export type ApplyToOptions = {
|
export type ApplyToOptions = {
|
||||||
excludedProperties: Set<keyof ElementPartial>;
|
excludedProperties?: Set<keyof ElementPartial>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApplyToFlags = {
|
type ApplyToFlags = {
|
||||||
containsVisibleDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
containsZindexDifference: boolean;
|
containsZindexDifference: boolean;
|
||||||
|
applyDirection: "forward" | "backward" | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1054,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
inserted,
|
inserted,
|
||||||
}: Delta<ElementPartial>) =>
|
}: Delta<ElementPartial>) =>
|
||||||
!!(
|
!!(
|
||||||
deleted.version &&
|
|
||||||
inserted.version &&
|
|
||||||
// versions are required integers
|
// versions are required integers
|
||||||
Number.isInteger(deleted.version) &&
|
(
|
||||||
Number.isInteger(inserted.version) &&
|
Number.isInteger(deleted.version) &&
|
||||||
// versions should be positive, zero included
|
Number.isInteger(inserted.version) &&
|
||||||
deleted.version >= 0 &&
|
// versions should be positive, zero included
|
||||||
inserted.version >= 0 &&
|
deleted.version! >= 0 &&
|
||||||
// versions should never be the same
|
inserted.version! >= 0 &&
|
||||||
deleted.version !== inserted.version
|
// versions should never be the same
|
||||||
|
deleted.version !== inserted.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private static satisfiesUniqueInvariants = (
|
||||||
|
elementsDelta: ElementsDelta,
|
||||||
|
id: string,
|
||||||
|
) => {
|
||||||
|
const { added, removed, updated } = elementsDelta;
|
||||||
|
// it's required that there is only one unique delta type per element
|
||||||
|
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||||
|
};
|
||||||
|
|
||||||
private static validate(
|
private static validate(
|
||||||
elementsDelta: ElementsDelta,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
@@ -1074,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (
|
if (
|
||||||
!this.satisfiesCommmonInvariants(delta) ||
|
!this.satisfiesCommmonInvariants(delta) ||
|
||||||
|
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||||
!satifiesSpecialInvariants(delta)
|
!satifiesSpecialInvariants(delta)
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1110,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const nextElement = nextElements.get(prevElement.id);
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
if (!nextElement) {
|
if (!nextElement) {
|
||||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
const deleted = { ...prevElement } as ElementPartial;
|
||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
@@ -1124,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
removed[prevElement.id] = delta;
|
if (!prevElement.isDeleted) {
|
||||||
|
removed[prevElement.id] = delta;
|
||||||
|
} else {
|
||||||
|
updated[prevElement.id] = delta;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
...nextElement,
|
...nextElement,
|
||||||
isDeleted: false,
|
|
||||||
} as ElementPartial;
|
} as ElementPartial;
|
||||||
|
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
@@ -1149,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
added[nextElement.id] = delta;
|
// ignore updates which would "delete" already deleted element
|
||||||
|
if (!nextElement.isDeleted) {
|
||||||
|
added[nextElement.id] = delta;
|
||||||
|
} else {
|
||||||
|
updated[nextElement.id] = delta;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1178,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// making sure there are at least some changes
|
updated[nextElement.id] = delta;
|
||||||
if (!Delta.isEmpty(delta)) {
|
|
||||||
updated[nextElement.id] = delta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,8 +1268,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(deltas)) {
|
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@@ -1316,9 +1388,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||||
options: ApplyToOptions = {
|
options?: ApplyToOptions,
|
||||||
excludedProperties: new Set(),
|
|
||||||
},
|
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = new Map(elements) as SceneElementsMap;
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
@@ -1326,22 +1396,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const flags: ApplyToFlags = {
|
const flags: ApplyToFlags = {
|
||||||
containsVisibleDifference: false,
|
containsVisibleDifference: false,
|
||||||
containsZindexDifference: false,
|
containsZindexDifference: false,
|
||||||
|
applyDirection: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsDelta.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
|
elements,
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
options,
|
|
||||||
flags,
|
flags,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas(this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas(this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas(this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
const affectedElements = this.resolveConflicts(
|
||||||
|
elements,
|
||||||
|
nextElements,
|
||||||
|
flags.applyDirection,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||||
changedElements = new Map([
|
changedElements = new Map([
|
||||||
@@ -1365,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||||
nextElements = ElementsDelta.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||||
// we also don't have a scene on the server
|
|
||||||
// so we are creating a temp scene just to query and mutate elements
|
|
||||||
const tempScene = new Scene(nextElements);
|
|
||||||
|
|
||||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
|
||||||
// Need ordered nextElements to avoid z-index binding issues
|
|
||||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@@ -1395,12 +1464,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public squash(delta: ElementsDelta): this {
|
||||||
|
if (delta.isEmpty()) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { added, removed, updated } = delta;
|
||||||
|
|
||||||
|
const mergeBoundElements = (
|
||||||
|
prevDelta: Delta<ElementPartial>,
|
||||||
|
nextDelta: Delta<ElementPartial>,
|
||||||
|
) => {
|
||||||
|
const mergedDeletedBoundElements =
|
||||||
|
Delta.mergeArrays(
|
||||||
|
prevDelta.deleted.boundElements ?? [],
|
||||||
|
nextDelta.deleted.boundElements ?? [],
|
||||||
|
undefined,
|
||||||
|
(x) => x.id,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const mergedInsertedBoundElements =
|
||||||
|
Delta.mergeArrays(
|
||||||
|
prevDelta.inserted.boundElements ?? [],
|
||||||
|
nextDelta.inserted.boundElements ?? [],
|
||||||
|
undefined,
|
||||||
|
(x) => x.id,
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!mergedDeletedBoundElements.length &&
|
||||||
|
!mergedInsertedBoundElements.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Delta.create(
|
||||||
|
{
|
||||||
|
boundElements: mergedDeletedBoundElements,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
boundElements: mergedInsertedBoundElements,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(added)) {
|
||||||
|
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.added[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||||
|
delete this.removed[id];
|
||||||
|
delete this.updated[id];
|
||||||
|
|
||||||
|
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||||
|
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.removed[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||||
|
delete this.added[id];
|
||||||
|
delete this.updated[id];
|
||||||
|
|
||||||
|
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||||
|
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||||
|
|
||||||
|
if (!prevDelta) {
|
||||||
|
this.updated[id] = nextDelta;
|
||||||
|
} else {
|
||||||
|
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||||
|
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||||
|
|
||||||
|
if (prevDelta === this.added[id]) {
|
||||||
|
this.added[id] = updatedDelta;
|
||||||
|
} else if (prevDelta === this.removed[id]) {
|
||||||
|
this.removed[id] = updatedDelta;
|
||||||
|
} else {
|
||||||
|
this.updated[id] = updatedDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTestEnv() || isDevEnv()) {
|
||||||
|
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
||||||
|
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
||||||
|
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
private static createApplier =
|
private static createApplier =
|
||||||
(
|
(
|
||||||
|
prevElements: SceneElementsMap,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"],
|
snapshot: StoreSnapshot["elements"],
|
||||||
options: ApplyToOptions,
|
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
|
options?: ApplyToOptions,
|
||||||
) =>
|
) =>
|
||||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const getElement = ElementsDelta.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
@@ -1413,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const newElement = ElementsDelta.applyDelta(
|
const nextElement = ElementsDelta.applyDelta(
|
||||||
element,
|
element,
|
||||||
delta,
|
delta,
|
||||||
options,
|
|
||||||
flags,
|
flags,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
nextElements.set(newElement.id, newElement);
|
nextElements.set(nextElement.id, nextElement);
|
||||||
acc.set(newElement.id, newElement);
|
acc.set(nextElement.id, nextElement);
|
||||||
|
|
||||||
|
if (!flags.applyDirection) {
|
||||||
|
const prevElement = prevElements.get(id);
|
||||||
|
|
||||||
|
if (prevElement) {
|
||||||
|
flags.applyDirection =
|
||||||
|
prevElement.version > nextElement.version
|
||||||
|
? "backward"
|
||||||
|
: "forward";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@@ -1466,8 +1647,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static applyDelta(
|
private static applyDelta(
|
||||||
element: OrderedExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
delta: Delta<ElementPartial>,
|
delta: Delta<ElementPartial>,
|
||||||
options: ApplyToOptions,
|
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
|
options?: ApplyToOptions,
|
||||||
) {
|
) {
|
||||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||||
|
|
||||||
@@ -1481,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.excludedProperties.has(key)) {
|
if (options?.excludedProperties?.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
delta.deleted.index !== delta.inserted.index;
|
delta.deleted.index !== delta.inserted.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElementWith(element, directlyApplicablePartial);
|
return newElementWith(element, directlyApplicablePartial, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1561,6 +1742,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
|
applyDirection: "forward" | "backward" = "forward",
|
||||||
) {
|
) {
|
||||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
const updater = (
|
const updater = (
|
||||||
@@ -1572,21 +1754,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prevElement = prevElements.get(element.id);
|
||||||
|
const nextVersion =
|
||||||
|
applyDirection === "forward"
|
||||||
|
? nextElement.version + 1
|
||||||
|
: nextElement.version - 1;
|
||||||
|
|
||||||
|
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||||
|
|
||||||
let affectedElement: OrderedExcalidrawElement;
|
let affectedElement: OrderedExcalidrawElement;
|
||||||
|
|
||||||
if (prevElements.get(element.id) === nextElement) {
|
if (prevElement === nextElement) {
|
||||||
// create the new element instance in case we didn't modify the element yet
|
// create the new element instance in case we didn't modify the element yet
|
||||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||||
affectedElement = newElementWith(
|
affectedElement = newElementWith(
|
||||||
nextElement,
|
nextElement,
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
{
|
||||||
|
...elementUpdates,
|
||||||
|
version: nextVersion,
|
||||||
|
},
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
affectedElement = mutateElement(
|
affectedElement = mutateElement(nextElement, nextElements, {
|
||||||
nextElement,
|
...elementUpdates,
|
||||||
nextElements,
|
// don't modify the version further, if it's already different
|
||||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
version:
|
||||||
);
|
prevElement?.version !== nextElement.version
|
||||||
|
? nextElement.version
|
||||||
|
: nextVersion,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
@@ -1624,25 +1821,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||||
// technically we could do better here if perf. would become an issue
|
this.squash(
|
||||||
const { added, removed, updated } = ElementsDelta.calculate(
|
// technically we could do better here if perf. would become an issue
|
||||||
prevAffectedElements,
|
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||||
nextAffectedElements,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(added)) {
|
|
||||||
this.added[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(removed)) {
|
|
||||||
this.removed[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, delta] of Object.entries(updated)) {
|
|
||||||
this.updated[id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextAffectedElements;
|
return nextAffectedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,6 +1888,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static redrawElements(
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||||
|
// we also don't have a scene on the server
|
||||||
|
// so we are creating a temp scene just to query and mutate elements
|
||||||
|
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||||
|
|
||||||
|
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||||
|
|
||||||
|
// needs ordered nextElements to avoid z-index binding issues
|
||||||
|
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Couldn't redraw elements`, e);
|
||||||
|
|
||||||
|
if (isTestEnv() || isDevEnv()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
return nextElements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static redrawTextBoundingBoxes(
|
private static redrawTextBoundingBoxes(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
@@ -1758,6 +1967,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
for (const element of changed.values()) {
|
for (const element of changed.values()) {
|
||||||
if (!element.isDeleted && isBindableElement(element)) {
|
if (!element.isDeleted && isBindableElement(element)) {
|
||||||
|
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
changedElements: changed,
|
changedElements: changed,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -359,6 +359,12 @@ const handleSegmentRelease = (
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!restoredPoints || restoredPoints.length < 2) {
|
||||||
|
throw new Error(
|
||||||
|
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextPoints: GlobalPoint[] = [];
|
const nextPoints: GlobalPoint[] = [];
|
||||||
|
|
||||||
// First part of the arrow are the old points
|
// First part of the arrow are the old points
|
||||||
@@ -706,7 +712,7 @@ const handleEndpointDrag = (
|
|||||||
endGlobalPoint: GlobalPoint,
|
endGlobalPoint: GlobalPoint,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
) => {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||||
@@ -741,8 +747,15 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second point connection and add the start point
|
// Calculate the moving second point connection and add the start point
|
||||||
{
|
{
|
||||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||||
|
|
||||||
|
if (!secondPoint || !thirdPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||||
const secondIsHorizontal = headingIsHorizontal(
|
const secondIsHorizontal = headingIsHorizontal(
|
||||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||||
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second to last point connection
|
// Calculate the moving second to last point connection
|
||||||
{
|
{
|
||||||
const secondToLastPoint =
|
const secondToLastPoint = globalUpdatedPoints.at(
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||||
const thirdToLastPoint =
|
);
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||||
|
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||||
thirdToLastPoint,
|
thirdToLastPoint,
|
||||||
@@ -2071,16 +2093,7 @@ const normalizeArrowElementUpdate = (
|
|||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
points: LocalPoint[];
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
} => {
|
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
let points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { LaserPointer, type Point } from "@excalidraw/laser-pointer";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
lineSegment,
|
||||||
|
pointFrom,
|
||||||
|
pointRotateRads,
|
||||||
|
round,
|
||||||
|
type LocalPoint,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
|
import getStroke from "perfect-freehand";
|
||||||
|
|
||||||
|
import { invariant } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { getElementBounds } from "./bounds";
|
||||||
|
|
||||||
|
import type { StrokeOptions } from "perfect-freehand";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
PointerType,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const STROKE_OPTIONS: Record<
|
||||||
|
PointerType | "default",
|
||||||
|
{ streamline: number; simplify: number }
|
||||||
|
> = {
|
||||||
|
default: {
|
||||||
|
streamline: 0.35,
|
||||||
|
simplify: 0.1,
|
||||||
|
},
|
||||||
|
mouse: {
|
||||||
|
streamline: 0.6,
|
||||||
|
simplify: 0.1,
|
||||||
|
},
|
||||||
|
pen: {
|
||||||
|
// for optimal performance, we use a lower streamline and simplify
|
||||||
|
streamline: 0.2,
|
||||||
|
simplify: 0.1,
|
||||||
|
},
|
||||||
|
touch: {
|
||||||
|
streamline: 0.65,
|
||||||
|
simplify: 0.1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const getFreedrawConfig = (eventType: string | null | undefined) => {
|
||||||
|
return (
|
||||||
|
STROKE_OPTIONS[(eventType as PointerType | null) || "default"] ||
|
||||||
|
STROKE_OPTIONS.default
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates simulated pressure based on velocity between consecutive points.
|
||||||
|
* Fast movement (large distances) -> lower pressure
|
||||||
|
* Slow movement (small distances) -> higher pressure
|
||||||
|
*/
|
||||||
|
const calculateVelocityBasedPressure = (
|
||||||
|
points: readonly LocalPoint[],
|
||||||
|
index: number,
|
||||||
|
fixedStrokeWidth: boolean | undefined,
|
||||||
|
maxDistance = 8, // Maximum expected distance for normalization
|
||||||
|
): number => {
|
||||||
|
if (fixedStrokeWidth) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First point gets highest pressure
|
||||||
|
// This avoid "a dot followed by a line" effect, •== when first stroke is "slow"
|
||||||
|
if (index === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x1, y1] = points[index - 1];
|
||||||
|
const [x2, y2] = points[index];
|
||||||
|
|
||||||
|
// Calculate distance between consecutive points
|
||||||
|
const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||||
|
|
||||||
|
// Normalize distance and invert for pressure (0 = fast/low pressure, 1 = slow/high pressure)
|
||||||
|
const normalizedDistance = Math.min(distance / maxDistance, 1);
|
||||||
|
const basePressure = Math.max(0.1, 1 - normalizedDistance * 0.7); // Range: 0.1 to 1.0
|
||||||
|
|
||||||
|
const constantPressure = 0.5;
|
||||||
|
const pressure = constantPressure + (basePressure - constantPressure);
|
||||||
|
|
||||||
|
return Math.max(0.1, Math.min(1.0, pressure));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFreedrawStroke = (element: ExcalidrawFreeDrawElement) => {
|
||||||
|
// Compose points as [x, y, pressure]
|
||||||
|
let points: [number, number, number][];
|
||||||
|
if (element.freedrawOptions?.fixedStrokeWidth) {
|
||||||
|
points = element.points.map(
|
||||||
|
([x, y]: LocalPoint): [number, number, number] => [x, y, 1],
|
||||||
|
);
|
||||||
|
} else if (element.simulatePressure) {
|
||||||
|
// Simulate pressure based on velocity between consecutive points
|
||||||
|
points = element.points.map(([x, y]: LocalPoint, i) => [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
calculateVelocityBasedPressure(
|
||||||
|
element.points,
|
||||||
|
i,
|
||||||
|
element.freedrawOptions?.fixedStrokeWidth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
points = element.points.map(([x, y]: LocalPoint, i) => {
|
||||||
|
const rawPressure = element.pressures?.[i] ?? 0.5;
|
||||||
|
|
||||||
|
const amplifiedPressure = Math.pow(rawPressure, 0.6);
|
||||||
|
const adjustedPressure = amplifiedPressure;
|
||||||
|
|
||||||
|
return [x, y, clamp(adjustedPressure, 0.1, 1.0)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamline =
|
||||||
|
element.freedrawOptions?.streamline ?? STROKE_OPTIONS.default.streamline;
|
||||||
|
const simplify =
|
||||||
|
element.freedrawOptions?.simplify ?? STROKE_OPTIONS.default.simplify;
|
||||||
|
|
||||||
|
const laser = new LaserPointer({
|
||||||
|
size: element.strokeWidth,
|
||||||
|
streamline,
|
||||||
|
simplify,
|
||||||
|
sizeMapping: ({ pressure: t }) => {
|
||||||
|
if (element.freedrawOptions?.fixedStrokeWidth) {
|
||||||
|
return 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.simulatePressure) {
|
||||||
|
return 0.2 + t * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.2 + t * 0.8;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pt of points) {
|
||||||
|
laser.addPoint(pt);
|
||||||
|
}
|
||||||
|
laser.close();
|
||||||
|
|
||||||
|
return laser.getStrokeOutline();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an SVG path for a freedraw element using LaserPointer logic.
|
||||||
|
* Uses actual pressure data if available, otherwise simulates pressure based on velocity.
|
||||||
|
* No streamline, smoothing, or simulation is performed.
|
||||||
|
*/
|
||||||
|
export const getFreeDrawSvgPath = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
): string => {
|
||||||
|
// legacy, for backwards compatibility
|
||||||
|
if (element.freedrawOptions === null) {
|
||||||
|
return _legacy_getFreeDrawSvgPath(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _transition_getFreeDrawSvgPath(element);
|
||||||
|
|
||||||
|
// return getSvgPathFromStroke(getFreedrawStroke(element));
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundPoint = (A: Point): string => {
|
||||||
|
return `${round(A[0], 4, "round")},${round(A[1], 4, "round")} `;
|
||||||
|
};
|
||||||
|
|
||||||
|
const average = (A: Point, B: Point): string => {
|
||||||
|
return `${round((A[0] + B[0]) / 2, 4, "round")},${round(
|
||||||
|
(A[1] + B[1]) / 2,
|
||||||
|
4,
|
||||||
|
"round",
|
||||||
|
)} `;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSvgPathFromStroke = (points: Point[]): string => {
|
||||||
|
const len = points.length;
|
||||||
|
|
||||||
|
if (len < 2) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let a = points[0];
|
||||||
|
let b = points[1];
|
||||||
|
|
||||||
|
if (len === 2) {
|
||||||
|
return `M${roundPoint(a)}L${roundPoint(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
|
||||||
|
for (let i = 2, max = len - 1; i < max; i++) {
|
||||||
|
a = points[i];
|
||||||
|
b = points[i + 1];
|
||||||
|
result += average(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `M${roundPoint(points[0])}Q${roundPoint(points[1])}${average(
|
||||||
|
points[1],
|
||||||
|
points[2],
|
||||||
|
)}${points.length > 3 ? "T" : ""}${result}L${roundPoint(points[len - 1])}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function _transition_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||||
|
const inputPoints = element.simulatePressure
|
||||||
|
? element.points
|
||||||
|
: element.points.length
|
||||||
|
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||||
|
: [[0, 0, 0.5]];
|
||||||
|
|
||||||
|
// Consider changing the options for simulated pressure vs real pressure
|
||||||
|
const options: StrokeOptions = {
|
||||||
|
simulatePressure: element.simulatePressure,
|
||||||
|
size: element.strokeWidth,
|
||||||
|
thinning: 0.6,
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.5,
|
||||||
|
easing: (t) => {
|
||||||
|
if (element.freedrawOptions?.fixedStrokeWidth) {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.sin((t * Math.PI) / 2) * 0.65;
|
||||||
|
}, // https://easings.net/#easeOutSine
|
||||||
|
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||||
|
};
|
||||||
|
|
||||||
|
return _legacy_getSvgPathFromStroke(
|
||||||
|
getStroke(inputPoints as number[][], options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _legacy_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||||
|
// If input points are empty (should they ever be?) return a dot
|
||||||
|
const inputPoints = element.simulatePressure
|
||||||
|
? element.points
|
||||||
|
: element.points.length
|
||||||
|
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||||
|
: [[0, 0, 0.5]];
|
||||||
|
|
||||||
|
// Consider changing the options for simulated pressure vs real pressure
|
||||||
|
const options: StrokeOptions = {
|
||||||
|
simulatePressure: element.simulatePressure,
|
||||||
|
size: element.strokeWidth * 4.25,
|
||||||
|
thinning: 0.6,
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.5,
|
||||||
|
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||||
|
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||||
|
};
|
||||||
|
|
||||||
|
return _legacy_getSvgPathFromStroke(
|
||||||
|
getStroke(inputPoints as number[][], options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const med = (A: number[], B: number[]) => {
|
||||||
|
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trim SVG path data so number are each two decimal points. This
|
||||||
|
// improves SVG exports, and prevents rendering errors on points
|
||||||
|
// with long decimals.
|
||||||
|
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||||
|
|
||||||
|
const _legacy_getSvgPathFromStroke = (points: number[][]): string => {
|
||||||
|
if (!points.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = points.length - 1;
|
||||||
|
|
||||||
|
return points
|
||||||
|
.reduce(
|
||||||
|
(acc, point, i, arr) => {
|
||||||
|
if (i === max) {
|
||||||
|
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||||
|
} else {
|
||||||
|
acc.push(point, med(point, arr[i + 1]));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
["M", points[0], "Q"],
|
||||||
|
)
|
||||||
|
.join(" ")
|
||||||
|
.replace(TO_FIXED_PRECISION, "$1");
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFreedrawOutlineAsSegments(
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
points: [number, number][],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) {
|
||||||
|
const bounds = getElementBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const center = pointFrom<GlobalPoint>(
|
||||||
|
(bounds[0] + bounds[2]) / 2,
|
||||||
|
(bounds[1] + bounds[3]) / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
|
||||||
|
|
||||||
|
return points.slice(2).reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc.push(
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
acc[acc.length - 1][1],
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[0][0] + element.x,
|
||||||
|
points[0][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[1][0] + element.x,
|
||||||
|
points[1][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||||
|
// If input points are empty (should they ever be?) return a dot
|
||||||
|
const inputPoints = element.simulatePressure
|
||||||
|
? element.points
|
||||||
|
: element.points.length
|
||||||
|
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||||
|
: [[0, 0, 0.5]];
|
||||||
|
|
||||||
|
// Consider changing the options for simulated pressure vs real pressure
|
||||||
|
const options: StrokeOptions = {
|
||||||
|
simulatePressure: element.simulatePressure,
|
||||||
|
size: element.strokeWidth * 4.25,
|
||||||
|
thinning: 0.6,
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.5,
|
||||||
|
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||||
|
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||||
|
};
|
||||||
|
|
||||||
|
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
|||||||
|
|
||||||
// string hash function (using djb2). Not cryptographically secure, use only
|
// string hash function (using djb2). Not cryptographically secure, use only
|
||||||
// for versioning and such.
|
// for versioning and such.
|
||||||
|
// note: hashes individual code units (not code points),
|
||||||
|
// but for hashing purposes this is fine as it iterates through every code unit
|
||||||
|
// (as such, no need to encode to byte string first)
|
||||||
export const hashString = (s: string): number => {
|
export const hashString = (s: string): number => {
|
||||||
let hash: number = 5381;
|
let hash: number = 5381;
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
@@ -91,12 +94,14 @@ export * from "./embeddable";
|
|||||||
export * from "./flowchart";
|
export * from "./flowchart";
|
||||||
export * from "./fractionalIndex";
|
export * from "./fractionalIndex";
|
||||||
export * from "./frame";
|
export * from "./frame";
|
||||||
|
export * from "./freedraw";
|
||||||
export * from "./groups";
|
export * from "./groups";
|
||||||
export * from "./heading";
|
export * from "./heading";
|
||||||
export * from "./image";
|
export * from "./image";
|
||||||
export * from "./linearElementEditor";
|
export * from "./linearElementEditor";
|
||||||
export * from "./mutateElement";
|
export * from "./mutateElement";
|
||||||
export * from "./newElement";
|
export * from "./newElement";
|
||||||
|
export * from "./positionElementsOnGrid";
|
||||||
export * from "./renderElement";
|
export * from "./renderElement";
|
||||||
export * from "./resizeElements";
|
export * from "./resizeElements";
|
||||||
export * from "./resizeTest";
|
export * from "./resizeTest";
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ export const newFreeDrawElement = (
|
|||||||
points?: ExcalidrawFreeDrawElement["points"];
|
points?: ExcalidrawFreeDrawElement["points"];
|
||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||||
|
strokeOptions?: ExcalidrawFreeDrawElement["freedrawOptions"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||||
return {
|
return {
|
||||||
@@ -453,6 +454,11 @@ export const newFreeDrawElement = (
|
|||||||
pressures: opts.pressures || [],
|
pressures: opts.pressures || [],
|
||||||
simulatePressure: opts.simulatePressure,
|
simulatePressure: opts.simulatePressure,
|
||||||
lastCommittedPoint: null,
|
lastCommittedPoint: null,
|
||||||
|
freedrawOptions: opts.strokeOptions || {
|
||||||
|
fixedStrokeWidth: true,
|
||||||
|
streamline: 0.25,
|
||||||
|
simplify: 0.1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { getCommonBounds } from "./bounds";
|
||||||
|
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
// TODO rewrite (mostly vibe-coded)
|
||||||
|
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||||
|
elements: TElement[] | TElement[][],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
padding = 50,
|
||||||
|
): TElement[] => {
|
||||||
|
// Ensure there are elements to position
|
||||||
|
if (!elements || elements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: TElement[] = [];
|
||||||
|
// Normalize input to work with atomic units (groups of elements)
|
||||||
|
// If elements is a flat array, treat each element as its own atomic unit
|
||||||
|
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
||||||
|
? (elements as TElement[][])
|
||||||
|
: (elements as TElement[]).map((element) => [element]);
|
||||||
|
|
||||||
|
// Determine the number of columns for atomic units
|
||||||
|
// A common approach for a "grid-like" layout without specific column constraints
|
||||||
|
// is to aim for a roughly square arrangement.
|
||||||
|
const numUnits = atomicUnits.length;
|
||||||
|
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
||||||
|
|
||||||
|
// Group atomic units into rows based on the calculated number of columns
|
||||||
|
const rows: TElement[][][] = [];
|
||||||
|
for (let i = 0; i < numUnits; i += numColumns) {
|
||||||
|
rows.push(atomicUnits.slice(i, i + numColumns));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate properties for each row (total width, max height)
|
||||||
|
// and the total actual height of all row content.
|
||||||
|
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
||||||
|
const rowProperties = rows.map((rowUnits) => {
|
||||||
|
let rowWidth = 0;
|
||||||
|
let maxUnitHeightInRow = 0;
|
||||||
|
|
||||||
|
const unitBounds = rowUnits.map((unit) => {
|
||||||
|
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
||||||
|
return {
|
||||||
|
elements: unit,
|
||||||
|
bounds: [minX, minY, maxX, maxY] as const,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
unitBounds.forEach((unitBound, index) => {
|
||||||
|
rowWidth += unitBound.width;
|
||||||
|
// Add padding between units in the same row, but not after the last one
|
||||||
|
if (index < unitBounds.length - 1) {
|
||||||
|
rowWidth += padding;
|
||||||
|
}
|
||||||
|
if (unitBound.height > maxUnitHeightInRow) {
|
||||||
|
maxUnitHeightInRow = unitBound.height;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalGridActualHeight += maxUnitHeightInRow;
|
||||||
|
return {
|
||||||
|
unitBounds,
|
||||||
|
width: rowWidth,
|
||||||
|
maxHeight: maxUnitHeightInRow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate the total height of the grid including padding between rows
|
||||||
|
const totalGridHeightWithPadding =
|
||||||
|
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
||||||
|
|
||||||
|
// Calculate the starting Y position to center the entire grid vertically around centerY
|
||||||
|
let currentY = centerY - totalGridHeightWithPadding / 2;
|
||||||
|
|
||||||
|
// Position atomic units row by row
|
||||||
|
rowProperties.forEach((rowProp) => {
|
||||||
|
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
||||||
|
|
||||||
|
// Calculate the starting X for the current row to center it horizontally around centerX
|
||||||
|
let currentX = centerX - rowWidth / 2;
|
||||||
|
|
||||||
|
unitBounds.forEach((unitBound) => {
|
||||||
|
// Calculate the offset needed to position this atomic unit
|
||||||
|
const [originalMinX, originalMinY] = unitBound.bounds;
|
||||||
|
const offsetX = currentX - originalMinX;
|
||||||
|
const offsetY = currentY - originalMinY;
|
||||||
|
|
||||||
|
// Apply the offset to all elements in this atomic unit
|
||||||
|
unitBound.elements.forEach((element) => {
|
||||||
|
res.push(
|
||||||
|
newElementWith(element, {
|
||||||
|
x: element.x + offsetX,
|
||||||
|
y: element.y + offsetY,
|
||||||
|
} as ElementUpdate<TElement>),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move X for the next unit in the row
|
||||||
|
currentX += unitBound.width + padding;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move Y to the starting position for the next row
|
||||||
|
// This accounts for the tallest unit in the current row and the inter-row padding
|
||||||
|
currentY += rowMaxHeight + padding;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
|
||||||
|
|
||||||
import { isRightAngleRads } from "@excalidraw/math";
|
import { isRightAngleRads } from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -58,6 +57,8 @@ import { getCornerRadius } from "./utils";
|
|||||||
|
|
||||||
import { ShapeCache } from "./shape";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
|
import { getFreeDrawSvgPath } from "./freedraw";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -70,7 +71,6 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { StrokeOptions } from "perfect-freehand";
|
|
||||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
|
|
||||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||||
@@ -1037,57 +1037,3 @@ export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
|||||||
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
||||||
return pathsCache.get(element);
|
return pathsCache.get(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|
||||||
// If input points are empty (should they ever be?) return a dot
|
|
||||||
const inputPoints = element.simulatePressure
|
|
||||||
? element.points
|
|
||||||
: element.points.length
|
|
||||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
|
||||||
: [[0, 0, 0.5]];
|
|
||||||
|
|
||||||
// Consider changing the options for simulated pressure vs real pressure
|
|
||||||
const options: StrokeOptions = {
|
|
||||||
simulatePressure: element.simulatePressure,
|
|
||||||
size: element.strokeWidth * 4.25,
|
|
||||||
thinning: 0.6,
|
|
||||||
smoothing: 0.5,
|
|
||||||
streamline: 0.5,
|
|
||||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
|
||||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
|
||||||
};
|
|
||||||
|
|
||||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
|
||||||
}
|
|
||||||
|
|
||||||
function med(A: number[], B: number[]) {
|
|
||||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim SVG path data so number are each two decimal points. This
|
|
||||||
// improves SVG exports, and prevents rendering errors on points
|
|
||||||
// with long decimals.
|
|
||||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
|
||||||
|
|
||||||
function getSvgPathFromStroke(points: number[][]): string {
|
|
||||||
if (!points.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = points.length - 1;
|
|
||||||
|
|
||||||
return points
|
|
||||||
.reduce(
|
|
||||||
(acc, point, i, arr) => {
|
|
||||||
if (i === max) {
|
|
||||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
|
||||||
} else {
|
|
||||||
acc.push(point, med(point, arr[i + 1]));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
["M", points[0], "Q"],
|
|
||||||
)
|
|
||||||
.join(" ")
|
|
||||||
.replace(TO_FIXED_PRECISION, "$1");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
getMinTextElementWidth,
|
getMinTextElementWidth,
|
||||||
@@ -225,7 +226,16 @@ const rotateSingleElement = (
|
|||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
|
|
||||||
if (textElement && !isArrowElement(element)) {
|
if (textElement && !isArrowElement(element)) {
|
||||||
scene.mutateElement(textElement, { angle });
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
textElement,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
scene.mutateElement(textElement, {
|
||||||
|
angle,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -416,9 +426,15 @@ const rotateMultipleElements = (
|
|||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundText,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
scene.mutateElement(boundText, {
|
scene.mutateElement(boundText, {
|
||||||
x: boundText.x + (rotatedCX - cx),
|
x,
|
||||||
y: boundText.y + (rotatedCY - cy),
|
y,
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
assertNever,
|
assertNever,
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
STROKE_WIDTH,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
@@ -202,7 +203,7 @@ export const generateRoughOptions = (
|
|||||||
// hachureGap because if not specified, roughjs uses strokeWidth to
|
// hachureGap because if not specified, roughjs uses strokeWidth to
|
||||||
// calculate them (and we don't want the fills to be modified)
|
// calculate them (and we don't want the fills to be modified)
|
||||||
fillWeight: element.strokeWidth / 2,
|
fillWeight: element.strokeWidth / 2,
|
||||||
hachureGap: element.strokeWidth * 4,
|
hachureGap: Math.min(element.strokeWidth, STROKE_WIDTH.bold) * 4,
|
||||||
roughness: adjustRoughness(element),
|
roughness: adjustRoughness(element),
|
||||||
stroke: element.strokeColor,
|
stroke: element.strokeColor,
|
||||||
preserveVertices:
|
preserveVertices:
|
||||||
@@ -806,15 +807,21 @@ const generateElementShape = (
|
|||||||
generateFreeDrawShape(element);
|
generateFreeDrawShape(element);
|
||||||
|
|
||||||
if (isPathALoop(element.points)) {
|
if (isPathALoop(element.points)) {
|
||||||
// generate rough polygon to fill freedraw shape
|
const points =
|
||||||
const simplifiedPoints = simplify(
|
element.freedrawOptions === null
|
||||||
element.points as Mutable<LocalPoint[]>,
|
? simplify(element.points as LocalPoint[], 0.75)
|
||||||
0.75,
|
: simplify(element.points as LocalPoint[], 1.5);
|
||||||
);
|
|
||||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
shape =
|
||||||
...generateRoughOptions(element),
|
element.freedrawOptions === null
|
||||||
stroke: "none",
|
? generator.curve(points, {
|
||||||
});
|
...generateRoughOptions(element),
|
||||||
|
stroke: "none",
|
||||||
|
})
|
||||||
|
: generator.polygon(points, {
|
||||||
|
...generateRoughOptions(element),
|
||||||
|
stroke: "none",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
shape = null;
|
shape = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
|||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
// internally used by history
|
// for internal use by history
|
||||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
|
// for public use as part of onIncrement API
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[DurableIncrement | EphemeralIncrement]
|
[DurableIncrement | EphemeralIncrement]
|
||||||
>();
|
>();
|
||||||
@@ -239,7 +240,6 @@ export class Store {
|
|||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
@@ -552,10 +552,26 @@ export class StoreDelta {
|
|||||||
public static load({
|
public static load({
|
||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
|
appState: { delta: appStateDelta },
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated);
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
|
const appState = AppStateDelta.create(appStateDelta);
|
||||||
|
|
||||||
return new this(id, elements, AppStateDelta.empty());
|
return new this(id, elements, appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squash the passed deltas into the aggregated delta instance.
|
||||||
|
*/
|
||||||
|
public static squash(...deltas: StoreDelta[]) {
|
||||||
|
const aggregatedDelta = StoreDelta.empty();
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
aggregatedDelta.elements.squash(delta.elements);
|
||||||
|
aggregatedDelta.appState.squash(delta.appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregatedDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -572,9 +588,7 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
options: ApplyToOptions = {
|
options?: ApplyToOptions,
|
||||||
excludedProperties: new Set(),
|
|
||||||
},
|
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
elements,
|
elements,
|
||||||
@@ -613,6 +627,10 @@ export class StoreDelta {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||||
|
}
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
}
|
}
|
||||||
@@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
|||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
selectedLinearElementIsEditing: null,
|
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
@@ -998,14 +1015,12 @@ export const getObservedAppState = (
|
|||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
selectedLinearElementId:
|
selectedLinearElement: appState.selectedLinearElement
|
||||||
(appState as AppState).selectedLinearElement?.elementId ??
|
? {
|
||||||
(appState as ObservedAppState).selectedLinearElementId ??
|
elementId: appState.selectedLinearElement.elementId,
|
||||||
null,
|
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||||
selectedLinearElementIsEditing:
|
}
|
||||||
(appState as AppState).selectedLinearElement?.isEditing ??
|
: null,
|
||||||
(appState as ObservedAppState).selectedLinearElementIsEditing ??
|
|
||||||
null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
|
|||||||
x =
|
x =
|
||||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||||
}
|
}
|
||||||
|
const angle = (container.angle ?? 0) as Radians;
|
||||||
|
|
||||||
|
if (angle !== 0) {
|
||||||
|
const contentCenter = pointFrom(
|
||||||
|
containerCoords.x + maxContainerWidth / 2,
|
||||||
|
containerCoords.y + maxContainerHeight / 2,
|
||||||
|
);
|
||||||
|
const textCenter = pointFrom(
|
||||||
|
x + boundTextElement.width / 2,
|
||||||
|
y + boundTextElement.height / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: rx - boundTextElement.width / 2,
|
||||||
|
y: ry - boundTextElement.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
isAndroid,
|
isAndroid,
|
||||||
isIOS,
|
isIOS,
|
||||||
|
isMobileOrTablet,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
@@ -326,7 +327,7 @@ export const getTransformHandles = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldShowBoundingBox = (
|
export const hasBoundingBox = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
@@ -345,5 +346,7 @@ export const shouldShowBoundingBox = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return element.points.length > 2;
|
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||||
|
// (also prob best for simplicity's sake)
|
||||||
|
return element.points.length > 2 && !isMobileOrTablet();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -380,6 +380,11 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
|||||||
pressures: readonly number[];
|
pressures: readonly number[];
|
||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
lastCommittedPoint: LocalPoint | null;
|
lastCommittedPoint: LocalPoint | null;
|
||||||
|
freedrawOptions: {
|
||||||
|
streamline?: number;
|
||||||
|
simplify?: number;
|
||||||
|
fixedStrokeWidth?: boolean;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FileId = string & { _brand: "FileId" };
|
export type FileId = string & { _brand: "FileId" };
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|||||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getTransformHandles } from "../src/transformHandles";
|
import { getTransformHandles } from "../src/transformHandles";
|
||||||
import {
|
import {
|
||||||
getTextEditor,
|
getTextEditor,
|
||||||
@@ -413,16 +415,12 @@ describe("element binding", () => {
|
|||||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||||
|
|
||||||
// Drag arrow off of bound rectangle range
|
// Drag arrow off of bound rectangle range
|
||||||
const handles = getTransformHandles(
|
const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
arrow,
|
arrow,
|
||||||
h.state.zoom,
|
-1,
|
||||||
arrayToMap(h.elements),
|
h.scene.getNonDeletedElementsMap(),
|
||||||
"mouse",
|
);
|
||||||
).se!;
|
|
||||||
|
|
||||||
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
||||||
const elX = handles[0] + handles[2] / 2;
|
|
||||||
const elY = handles[1] + handles[3] / 2;
|
|
||||||
mouse.downAt(elX, elY);
|
mouse.downAt(elX, elY);
|
||||||
mouse.moveTo(300, 400);
|
mouse.moveTo(300, 400);
|
||||||
mouse.up();
|
mouse.up();
|
||||||
|
|||||||
@@ -1,13 +1,345 @@
|
|||||||
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { LinearElementEditor } from "@excalidraw/element";
|
import type { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { AppStateDelta } from "../src/delta";
|
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||||
|
|
||||||
|
describe("ElementsDelta", () => {
|
||||||
|
describe("elements delta calculation", () => {
|
||||||
|
it("should not throw when element gets removed but was already deleted", () => {
|
||||||
|
const element = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevElements = new Map([[element.id, element]]);
|
||||||
|
const nextElements = new Map();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ElementsDelta.calculate(prevElements, nextElements),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw when adding element as already deleted", () => {
|
||||||
|
const element = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevElements = new Map();
|
||||||
|
const nextElements = new Map([[element.id, element]]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ElementsDelta.calculate(prevElements, nextElements),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create updated delta even when there is only version and versionNonce change", () => {
|
||||||
|
const baseElement = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
strokeColor: "#000000",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const modifiedElement = {
|
||||||
|
...baseElement,
|
||||||
|
version: baseElement.version + 1,
|
||||||
|
versionNonce: baseElement.versionNonce + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create maps for the delta calculation
|
||||||
|
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||||
|
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||||
|
|
||||||
|
// Calculate the delta
|
||||||
|
const delta = ElementsDelta.calculate(
|
||||||
|
prevElements as SceneElementsMap,
|
||||||
|
nextElements as SceneElementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(delta).toEqual(
|
||||||
|
ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
[baseElement.id]: Delta.create(
|
||||||
|
{
|
||||||
|
version: baseElement.version,
|
||||||
|
versionNonce: baseElement.versionNonce,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: baseElement.version + 1,
|
||||||
|
versionNonce: baseElement.versionNonce + 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("squash", () => {
|
||||||
|
it("should not squash when second delta is empty", () => {
|
||||||
|
const updatedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ id1: updatedDelta },
|
||||||
|
);
|
||||||
|
const elementsDelta2 = ElementsDelta.empty();
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash mutually exclusive delta types", () => {
|
||||||
|
const addedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const removedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedDelta = Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{ id1: addedDelta },
|
||||||
|
{ id2: removedDelta },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{ id3: updatedDelta },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||||
|
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||||
|
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash the same delta types", () => {
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 200, version: 2, versionNonce: 2 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2 },
|
||||||
|
{ y: 200, version: 3, versionNonce: 3 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.added.id1).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(elementsDelta.removed.id2).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(elementsDelta.updated.id3).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||||
|
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash different delta types ", () => {
|
||||||
|
// id1: added -> updated => added
|
||||||
|
// id2: removed -> added => added
|
||||||
|
// id3: updated -> removed => removed
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||||
|
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||||
|
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ x: 300, version: 1, versionNonce: 1 },
|
||||||
|
{ x: 301, version: 2, versionNonce: 2 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{
|
||||||
|
id2: Delta.create(
|
||||||
|
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id3: Delta.create(
|
||||||
|
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{ y: 100, version: 2, versionNonce: 2 },
|
||||||
|
{ y: 101, version: 3, versionNonce: 3 },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(elementsDelta).toBe(elementsDelta1);
|
||||||
|
expect(elementsDelta.added).toEqual({
|
||||||
|
id1: Delta.create(
|
||||||
|
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
id2: Delta.create(
|
||||||
|
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||||
|
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(elementsDelta.removed).toEqual({
|
||||||
|
id3: Delta.create(
|
||||||
|
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||||
|
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(elementsDelta.updated).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash bound elements", () => {
|
||||||
|
const elementsDelta1 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
boundElements: [{ id: "t1", type: "text" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
versionNonce: 2,
|
||||||
|
boundElements: [{ id: "t2", type: "text" }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta2 = ElementsDelta.create(
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
id1: Delta.create(
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
versionNonce: 2,
|
||||||
|
boundElements: [{ id: "a1", type: "arrow" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
versionNonce: 3,
|
||||||
|
boundElements: [{ id: "a2", type: "arrow" }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||||
|
|
||||||
|
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||||
|
{ id: "t1", type: "text" },
|
||||||
|
{ id: "a1", type: "arrow" },
|
||||||
|
]);
|
||||||
|
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||||
|
{ id: "t2", type: "text" },
|
||||||
|
{ id: "a2", type: "arrow" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("AppStateDelta", () => {
|
describe("AppStateDelta", () => {
|
||||||
describe("ensure stable delta properties order", () => {
|
describe("ensure stable delta properties order", () => {
|
||||||
it("should maintain stable order for root properties", () => {
|
it("should maintain stable order for root properties", () => {
|
||||||
const name = "untitled scene";
|
const name = "untitled scene";
|
||||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
const selectedLinearElement = {
|
||||||
|
elementId: "id1" as LinearElementEditor["elementId"],
|
||||||
|
isEditing: false,
|
||||||
|
};
|
||||||
|
|
||||||
const commonAppState = {
|
const commonAppState = {
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
@@ -24,23 +356,23 @@ describe("AppStateDelta", () => {
|
|||||||
const prevAppState1: ObservedAppState = {
|
const prevAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name: "",
|
name: "",
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
const nextAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name,
|
name,
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
const prevAppState2: ObservedAppState = {
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
name: "",
|
name: "",
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
const nextAppState2: ObservedAppState = {
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
name,
|
name,
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
@@ -58,9 +390,7 @@ describe("AppStateDelta", () => {
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
selectedLinearElementIsEditing: null,
|
|
||||||
editingLinearElementId: null,
|
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@@ -106,9 +436,7 @@ describe("AppStateDelta", () => {
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
selectedLinearElementIsEditing: null,
|
|
||||||
editingLinearElementId: null,
|
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@@ -149,4 +477,97 @@ describe("AppStateDelta", () => {
|
|||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("squash", () => {
|
||||||
|
it("should not squash when second delta is empty", () => {
|
||||||
|
const delta = Delta.create(
|
||||||
|
{ name: "untitled scene" },
|
||||||
|
{ name: "titled scene" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStateDelta1 = AppStateDelta.create(delta);
|
||||||
|
const appStateDelta2 = AppStateDelta.empty();
|
||||||
|
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||||
|
|
||||||
|
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(appStateDelta).toBe(appStateDelta1);
|
||||||
|
expect(appStateDelta.delta).toBe(delta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash exclusive properties", () => {
|
||||||
|
const delta1 = Delta.create(
|
||||||
|
{ name: "untitled scene" },
|
||||||
|
{ name: "titled scene" },
|
||||||
|
);
|
||||||
|
const delta2 = Delta.create(
|
||||||
|
{ viewBackgroundColor: "#ffffff" },
|
||||||
|
{ viewBackgroundColor: "#000000" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||||
|
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||||
|
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||||
|
|
||||||
|
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(appStateDelta).toBe(appStateDelta1);
|
||||||
|
expect(appStateDelta.delta).toEqual(
|
||||||
|
Delta.create(
|
||||||
|
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||||
|
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||||
|
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||||
|
{
|
||||||
|
name: "untitled scene",
|
||||||
|
selectedElementIds: { id1: true },
|
||||||
|
selectedGroupIds: {},
|
||||||
|
lockedMultiSelections: { g1: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "titled scene",
|
||||||
|
selectedElementIds: { id2: true },
|
||||||
|
selectedGroupIds: { g1: true },
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||||
|
{
|
||||||
|
selectedElementIds: { id3: true },
|
||||||
|
selectedGroupIds: { g1: true },
|
||||||
|
lockedMultiSelections: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selectedElementIds: { id2: true },
|
||||||
|
selectedGroupIds: { g2: true, g3: true },
|
||||||
|
lockedMultiSelections: { g3: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||||
|
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||||
|
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||||
|
|
||||||
|
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||||
|
expect(appStateDelta).toBe(appStateDelta1);
|
||||||
|
expect(appStateDelta.delta).toEqual(
|
||||||
|
Delta.create<Partial<ObservedAppState>>(
|
||||||
|
{
|
||||||
|
name: "untitled scene",
|
||||||
|
selectedElementIds: { id1: true, id3: true },
|
||||||
|
selectedGroupIds: { g1: true },
|
||||||
|
lockedMultiSelections: { g1: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "titled scene",
|
||||||
|
selectedElementIds: { id2: true },
|
||||||
|
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||||
|
lockedMultiSelections: { g3: true },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { getLineHeight } from "@excalidraw/common";
|
import { getLineHeight } from "@excalidraw/common";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "../src/textElement";
|
} from "../src/textElement";
|
||||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||||
|
|
||||||
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
|
|||||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Test computeBoundTextPosition", () => {
|
||||||
|
const createMockElementsMap = () => new Map();
|
||||||
|
|
||||||
|
// Helper function to create rectangle test case with 90-degree rotation
|
||||||
|
const createRotatedRectangleTestCase = (
|
||||||
|
textAlign: string,
|
||||||
|
verticalAlign: string,
|
||||||
|
) => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
angle: (Math.PI / 2) as any, // 90 degrees
|
||||||
|
});
|
||||||
|
|
||||||
|
const boundTextElement = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
text: "hello darkness my old friend",
|
||||||
|
textAlign: textAlign as any,
|
||||||
|
verticalAlign: verticalAlign as any,
|
||||||
|
containerId: container.id,
|
||||||
|
}) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
|
const elementsMap = createMockElementsMap();
|
||||||
|
|
||||||
|
return { container, boundTextElement, elementsMap };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("90-degree rotation with all alignment combinations", () => {
|
||||||
|
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
|
||||||
|
|
||||||
|
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(
|
||||||
|
TEXT_ALIGN.CENTER,
|
||||||
|
VERTICAL_ALIGN.MIDDLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(
|
||||||
|
TEXT_ALIGN.CENTER,
|
||||||
|
VERTICAL_ALIGN.BOTTOM,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { alignElements } from "@excalidraw/element";
|
import { alignElements } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
getOriginalContainerHeightFromCache,
|
getOriginalContainerHeightFromCache,
|
||||||
|
isBoundToContainer,
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({
|
|||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
const someTextElements = selectedElements.some((el) => isTextElement(el));
|
const someTextElements = selectedElements.some(
|
||||||
|
(el) => isTextElement(el) && !isBoundToContainer(el),
|
||||||
|
);
|
||||||
return selectedElements.length > 0 && someTextElements;
|
return selectedElements.length > 0 && someTextElements;
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -234,7 +237,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||||
|
|
||||||
for (const textElement of selectedElements) {
|
for (const textElement of selectedElements) {
|
||||||
if (isTextElement(textElement)) {
|
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
|
||||||
const container = newElement({
|
const container = newElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
backgroundColor: appState.currentItemBackgroundColor,
|
backgroundColor: appState.currentItemBackgroundColor,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
MIN_ZOOM,
|
MIN_ZOOM,
|
||||||
THEME,
|
THEME,
|
||||||
ZOOM_STEP,
|
ZOOM_STEP,
|
||||||
getShortcutKey,
|
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
CODES,
|
CODES,
|
||||||
KEYS,
|
KEYS,
|
||||||
@@ -46,6 +45,7 @@ import { t } from "../i18n";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -121,7 +122,10 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: "selection" }
|
? {
|
||||||
|
...appState.activeTool,
|
||||||
|
type: app.state.preferredSelectionTool.type,
|
||||||
|
}
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@@ -494,13 +498,13 @@ export const actionToggleEraserTool = register({
|
|||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
label: "toolBar.eraser",
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: "selection",
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@@ -530,6 +534,9 @@ export const actionToggleLassoTool = register({
|
|||||||
label: "toolBar.lasso",
|
label: "toolBar.lasso",
|
||||||
icon: LassoIcon,
|
icon: LassoIcon,
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
predicate: (elements, appState, props, app) => {
|
||||||
|
return app.state.preferredSelectionTool.type !== "lasso";
|
||||||
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
import {
|
||||||
|
KEYS,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||||
@@ -298,7 +302,9 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
activeTool: updateActiveTool(appState, {
|
||||||
|
type: app.state.preferredSelectionTool.type,
|
||||||
|
}),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
@@ -321,7 +327,15 @@ export const actionDeleteSelected = register({
|
|||||||
title={t("labels.delete")}
|
title={t("labels.delete")}
|
||||||
aria-label={t("labels.delete")}
|
aria-label={t("labels.delete")}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
disabled={
|
||||||
|
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile" &&
|
||||||
|
appState.openPopup !== "compactOtherProperties"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { isFrameLikeElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_GRID_SIZE,
|
DEFAULT_GRID_SIZE,
|
||||||
KEYS,
|
KEYS,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
getShortcutKey,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
@@ -25,6 +25,7 @@ import { DuplicateIcon } from "../components/icons";
|
|||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({
|
|||||||
)}`}
|
)}`}
|
||||||
aria-label={t("labels.duplicateSelection")}
|
aria-label={t("labels.duplicateSelection")}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
disabled={
|
||||||
|
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile" &&
|
||||||
|
appState.openPopup !== "compactOtherProperties"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element/binding";
|
||||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
import {
|
||||||
|
isValidPolygon,
|
||||||
|
LinearElementEditor,
|
||||||
|
newElementWith,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
@@ -78,7 +82,14 @@ export const actionFinalize = register({
|
|||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
newElements = newElements.map((el) => {
|
||||||
|
if (el.id === element.id) {
|
||||||
|
return newElementWith(el, {
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
@@ -117,7 +128,12 @@ export const actionFinalize = register({
|
|||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
? elements.filter((el) => el.id !== element.id)
|
? elements.map((el) => {
|
||||||
|
if (el.id === element.id) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -172,7 +188,12 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
newElements = newElements.map((el) => {
|
||||||
|
if (el.id === element?.id) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
@@ -240,13 +261,13 @@ export const actionFinalize = register({
|
|||||||
if (appState.activeTool.type === "eraser") {
|
if (appState.activeTool.type === "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: "selection",
|
type: app.state.preferredSelectionTool.type,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: "selection",
|
type: app.state.preferredSelectionTool.type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
@@ -43,6 +43,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
import {
|
||||||
|
isWindows,
|
||||||
|
KEYS,
|
||||||
|
matchKey,
|
||||||
|
arrayToMap,
|
||||||
|
MOBILE_ACTION_BUTTON_BG,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(
|
||||||
@@ -85,6 +91,11 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isUndoStackEmpty}
|
disabled={isUndoStackEmpty}
|
||||||
data-testid="button-undo"
|
data-testid="button-undo"
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -103,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const { isRedoStackEmpty } = useEmitter(
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
history.onHistoryChangedEmitter,
|
history.onHistoryChangedEmitter,
|
||||||
new HistoryChangedEvent(
|
new HistoryChangedEvent(
|
||||||
@@ -121,6 +132,11 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
size={data?.size || "medium"}
|
size={data?.size || "medium"}
|
||||||
disabled={isRedoStackEmpty}
|
disabled={isRedoStackEmpty}
|
||||||
data-testid="button-redo"
|
data-testid="button-redo"
|
||||||
|
style={{
|
||||||
|
...(appState.stylesPanelMode === "mobile"
|
||||||
|
? MOBILE_ACTION_BUTTON_BG
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
if (!selectedElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const label = t(
|
const label = t(
|
||||||
selectedElement.type === "arrow"
|
selectedElement.type === "arrow"
|
||||||
? "labels.lineEditor.editArrow"
|
? "labels.lineEditor.editArrow"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isEmbeddableElement } from "@excalidraw/element";
|
import { isEmbeddableElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
import { LinkIcon } from "../components/icons";
|
import { LinkIcon } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,11 @@
|
|||||||
import { KEYS } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { HelpIconThin } from "../components/icons";
|
||||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
|
||||||
name: "toggleCanvasMenu",
|
|
||||||
label: "buttons.menu",
|
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_, appState) => ({
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
}),
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={HamburgerMenuIcon}
|
|
||||||
aria-label={t("buttons.menu")}
|
|
||||||
onClick={updateData}
|
|
||||||
selected={appState.openMenu === "canvas"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
|
||||||
name: "toggleEditMenu",
|
|
||||||
label: "buttons.edit",
|
|
||||||
trackEvent: { category: "menu" },
|
|
||||||
perform: (_elements, appState) => ({
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
||||||
}),
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
|
||||||
<ToolButton
|
|
||||||
visible={showSelectedShapeActions(
|
|
||||||
appState,
|
|
||||||
getNonDeletedElements(elements),
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
icon={palette}
|
|
||||||
aria-label={t("buttons.edit")}
|
|
||||||
onClick={updateData}
|
|
||||||
selected={appState.openMenu === "shape"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
label: "welcomeScreen.defaults.helpHint",
|
label: "welcomeScreen.defaults.helpHint",
|
||||||
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
|
|||||||
: {
|
: {
|
||||||
name: "help",
|
name: "help",
|
||||||
},
|
},
|
||||||
|
openMenu: null,
|
||||||
|
openPopup: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,26 +145,27 @@ describe("element locking", () => {
|
|||||||
queryByTestId(document.body, `strokeWidth-thin`),
|
queryByTestId(document.body, `strokeWidth-thin`),
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
expect(
|
expect(
|
||||||
queryByTestId(document.body, `strokeWidth-bold`),
|
queryByTestId(document.body, `strokeWidth-medium`),
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
expect(
|
expect(
|
||||||
queryByTestId(document.body, `strokeWidth-extraBold`),
|
queryByTestId(document.body, `strokeWidth-bold`),
|
||||||
).not.toBeChecked();
|
).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show properties of different element types when selected", () => {
|
it("should show properties of different element types when selected", () => {
|
||||||
const rect = API.createElement({
|
const rect = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
strokeWidth: STROKE_WIDTH.bold,
|
strokeWidth: STROKE_WIDTH.medium,
|
||||||
});
|
});
|
||||||
const text = API.createElement({
|
const text = API.createElement({
|
||||||
type: "text",
|
type: "text",
|
||||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||||
|
strokeWidth: undefined,
|
||||||
});
|
});
|
||||||
API.setElements([rect, text]);
|
API.setElements([rect, text]);
|
||||||
API.setSelectedElements([rect, text]);
|
API.setSelectedElements([rect, text]);
|
||||||
|
|
||||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
|
||||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||||
"active",
|
"active",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
randomInteger,
|
randomInteger,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
getShortcutKey,
|
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
@@ -44,6 +43,7 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -126,6 +126,9 @@ import {
|
|||||||
ArrowheadCrowfootIcon,
|
ArrowheadCrowfootIcon,
|
||||||
ArrowheadCrowfootOneIcon,
|
ArrowheadCrowfootOneIcon,
|
||||||
ArrowheadCrowfootOneOrManyIcon,
|
ArrowheadCrowfootOneOrManyIcon,
|
||||||
|
strokeWidthFixedIcon,
|
||||||
|
strokeWidthVariableIcon,
|
||||||
|
StrokeWidthMediumIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
|
|
||||||
import { Fonts } from "../fonts";
|
import { Fonts } from "../fonts";
|
||||||
@@ -137,6 +140,13 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
|
import {
|
||||||
|
withCaretPositionPreservation,
|
||||||
|
restoreCaretPosition,
|
||||||
|
} from "../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
@@ -321,9 +331,11 @@ export const actionChangeStrokeColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||||
@@ -341,6 +353,10 @@ export const actionChangeStrokeColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -398,9 +414,11 @@ export const actionChangeBackgroundColor = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||||
@@ -418,6 +436,10 @@ export const actionChangeBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -503,6 +525,33 @@ export const actionChangeFillStyle = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WIDTHS = [
|
||||||
|
{
|
||||||
|
value: STROKE_WIDTH.thin,
|
||||||
|
text: t("labels.thin"),
|
||||||
|
icon: StrokeWidthBaseIcon,
|
||||||
|
testId: "strokeWidth-thin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: STROKE_WIDTH.medium,
|
||||||
|
text: t("labels.medium"),
|
||||||
|
icon: StrokeWidthMediumIcon,
|
||||||
|
testId: "strokeWidth-medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: STROKE_WIDTH.bold,
|
||||||
|
text: t("labels.bold"),
|
||||||
|
icon: StrokeWidthBoldIcon,
|
||||||
|
testId: "strokeWidth-bold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: STROKE_WIDTH.extraBold,
|
||||||
|
text: t("labels.extraBold"),
|
||||||
|
icon: StrokeWidthExtraBoldIcon,
|
||||||
|
testId: "strokeWidth-extraBold",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
label: "labels.strokeWidth",
|
label: "labels.strokeWidth",
|
||||||
@@ -518,32 +567,13 @@ export const actionChangeStrokeWidth = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
options={[
|
options={WIDTHS}
|
||||||
{
|
|
||||||
value: STROKE_WIDTH.thin,
|
|
||||||
text: t("labels.thin"),
|
|
||||||
icon: StrokeWidthBaseIcon,
|
|
||||||
testId: "strokeWidth-thin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: STROKE_WIDTH.bold,
|
|
||||||
text: t("labels.bold"),
|
|
||||||
icon: StrokeWidthBoldIcon,
|
|
||||||
testId: "strokeWidth-bold",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: STROKE_WIDTH.extraBold,
|
|
||||||
text: t("labels.extraBold"),
|
|
||||||
icon: StrokeWidthExtraBoldIcon,
|
|
||||||
testId: "strokeWidth-extraBold",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
app,
|
app,
|
||||||
@@ -575,7 +605,7 @@ export const actionChangeSloppiness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -628,7 +658,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -666,6 +696,70 @@ export const actionChangeStrokeStyle = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangePressureSensitivity = register({
|
||||||
|
name: "changeStrokeType",
|
||||||
|
label: "labels.strokeType",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
const updatedElements = changeProperty(elements, appState, (el) => {
|
||||||
|
if (isFreeDrawElement(el)) {
|
||||||
|
return newElementWith(el, {
|
||||||
|
freedrawOptions: {
|
||||||
|
...el.freedrawOptions,
|
||||||
|
fixedStrokeWidth: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: updatedElements,
|
||||||
|
appState: { ...appState, currentItemFixedStrokeWidth: value },
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ app, appState, updateData }) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||||
|
const freedraws = selectedElements.filter(isFreeDrawElement);
|
||||||
|
|
||||||
|
const currentValue =
|
||||||
|
freedraws.length > 0
|
||||||
|
? reduceToCommonValue(
|
||||||
|
freedraws,
|
||||||
|
(element) => element.freedrawOptions?.fixedStrokeWidth,
|
||||||
|
) ?? null
|
||||||
|
: appState.currentItemFixedStrokeWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.strokeType")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
<RadioSelection
|
||||||
|
group="pressure-sensitivity"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: true,
|
||||||
|
text: t("labels.strokeWidthFixed"),
|
||||||
|
icon: strokeWidthFixedIcon,
|
||||||
|
testId: "pressure-fixed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
text: t("labels.strokeWidthVariable"),
|
||||||
|
icon: strokeWidthVariableIcon,
|
||||||
|
testId: "pressure-variable",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(value) => updateData(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
label: "labels.opacity",
|
label: "labels.opacity",
|
||||||
@@ -697,7 +791,7 @@ export const actionChangeFontSize = register({
|
|||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -756,7 +850,15 @@ export const actionChangeFontSize = register({
|
|||||||
? null
|
? null
|
||||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1093,21 +1195,30 @@ export const actionChangeFontFamily = register({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
|
)}
|
||||||
<FontPicker
|
<FontPicker
|
||||||
isOpened={appState.openPopup === "fontFamily"}
|
isOpened={appState.openPopup === "fontFamily"}
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||||
|
compactMode={appState.stylesPanelMode !== "full"}
|
||||||
onSelect={(fontFamily) => {
|
onSelect={(fontFamily) => {
|
||||||
setBatchedData({
|
withCaretPositionPreservation(
|
||||||
openPopup: null,
|
() => {
|
||||||
currentHoveredFontFamily: null,
|
setBatchedData({
|
||||||
currentItemFontFamily: fontFamily,
|
openPopup: null,
|
||||||
});
|
currentHoveredFontFamily: null,
|
||||||
|
currentItemFontFamily: fontFamily,
|
||||||
// defensive clear so immediate close won't abuse the cached elements
|
});
|
||||||
cachedElementsRef.current.clear();
|
// defensive clear so immediate close won't abuse the cached elements
|
||||||
|
cachedElementsRef.current.clear();
|
||||||
|
},
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onHover={(fontFamily) => {
|
onHover={(fontFamily) => {
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
@@ -1164,29 +1275,33 @@ export const actionChangeFontFamily = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
|
...batchedData,
|
||||||
openPopup: "fontFamily",
|
openPopup: "fontFamily",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// close, use the cache and clear it afterwards
|
const fontFamilyData = {
|
||||||
const data = {
|
|
||||||
openPopup: null,
|
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cachedElements: new Map(cachedElementsRef.current),
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
resetAll: true,
|
resetAll: true,
|
||||||
} as ChangeFontFamilyData;
|
} as ChangeFontFamilyData;
|
||||||
|
|
||||||
if (isUnmounted.current) {
|
setBatchedData({
|
||||||
// in case the component was unmounted by the parent, trigger the update directly
|
...fontFamilyData,
|
||||||
updateData({ ...batchedData, ...data });
|
});
|
||||||
} else {
|
|
||||||
setBatchedData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (
|
||||||
|
(appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile") &&
|
||||||
|
appState.editingTextElement
|
||||||
|
) {
|
||||||
|
restoreCaretPosition(null); // Just refocus without saved position
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1225,8 +1340,9 @@ export const actionChangeTextAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
@@ -1275,7 +1391,15 @@ export const actionChangeTextAlign = register({
|
|||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemTextAlign,
|
hasSelection ? null : appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1317,7 +1441,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -1367,7 +1491,15 @@ export const actionChangeVerticalAlign = register({
|
|||||||
) !== null,
|
) !== null,
|
||||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1616,6 +1748,25 @@ export const actionChangeArrowhead = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeArrowProperties = register({
|
||||||
|
name: "changeArrowProperties",
|
||||||
|
label: "Change arrow properties",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value, app) => {
|
||||||
|
// This action doesn't perform any changes directly
|
||||||
|
// It's just a container for the arrow type and arrowhead actions
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||||
|
return (
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{renderAction("changeArrowhead")}
|
||||||
|
{renderAction("changeArrowType")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeArrowType = register({
|
export const actionChangeArrowType = register({
|
||||||
name: "changeArrowType",
|
name: "changeArrowType",
|
||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
|
import { KEYS, CODES, isDarwin } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
moveOneLeft,
|
moveOneLeft,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ export {
|
|||||||
actionChangeStrokeWidth,
|
actionChangeStrokeWidth,
|
||||||
actionChangeFillStyle,
|
actionChangeFillStyle,
|
||||||
actionChangeSloppiness,
|
actionChangeSloppiness,
|
||||||
|
actionChangePressureSensitivity,
|
||||||
actionChangeOpacity,
|
actionChangeOpacity,
|
||||||
actionChangeFontSize,
|
actionChangeFontSize,
|
||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
|
actionChangeArrowProperties,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -43,11 +45,7 @@ export {
|
|||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||||
export {
|
export { actionShortcuts } from "./actionMenu";
|
||||||
actionToggleCanvasMenu,
|
|
||||||
actionToggleEditMenu,
|
|
||||||
actionShortcuts,
|
|
||||||
} from "./actionMenu";
|
|
||||||
|
|
||||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { isDarwin, getShortcutKey } from "@excalidraw/common";
|
import { isDarwin } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import type { ActionName } from "./types";
|
import type { ActionName } from "./types";
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ export type ActionName =
|
|||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
|
| "changeStrokeType"
|
||||||
|
| "changeArrowProperties"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
|
||||||
| "toggleEditMenu"
|
|
||||||
| "undo"
|
| "undo"
|
||||||
| "redo"
|
| "redo"
|
||||||
| "finalize"
|
| "finalize"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||||
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
|
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
|
||||||
|
currentItemFixedStrokeWidth: true,
|
||||||
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
||||||
currentItemStartArrowhead: null,
|
currentItemStartArrowhead: null,
|
||||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||||
@@ -55,6 +56,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
fromSelection: false,
|
fromSelection: false,
|
||||||
lastActiveTool: null,
|
lastActiveTool: null,
|
||||||
},
|
},
|
||||||
|
preferredSelectionTool: {
|
||||||
|
type: "selection",
|
||||||
|
initialized: false,
|
||||||
|
},
|
||||||
penMode: false,
|
penMode: false,
|
||||||
penDetected: false,
|
penDetected: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -123,6 +128,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
searchMatches: null,
|
searchMatches: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
|
stylesPanelMode: "full",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,6 +168,11 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
server: false,
|
server: false,
|
||||||
},
|
},
|
||||||
currentItemOpacity: { browser: true, export: false, server: false },
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
|
currentItemFixedStrokeWidth: {
|
||||||
|
browser: true,
|
||||||
|
export: false,
|
||||||
|
server: false,
|
||||||
|
},
|
||||||
currentItemRoughness: { browser: true, export: false, server: false },
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
@@ -175,6 +186,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
editingTextElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
|
preferredSelectionTool: { browser: true, export: false, server: false },
|
||||||
penMode: { browser: true, export: false, server: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
penDetected: { browser: true, export: false, server: false },
|
penDetected: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false, server: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
@@ -247,6 +259,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||||
activeLockedId: { browser: false, export: false, server: false },
|
activeLockedId: { browser: false, export: false, server: false },
|
||||||
|
stylesPanelMode: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createPasteEvent,
|
createPasteEvent,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
|
parseDataTransferEvent,
|
||||||
serializeAsClipboardJSON,
|
serializeAsClipboardJSON,
|
||||||
} from "./clipboard";
|
} from "./clipboard";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "123";
|
text = "123";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = JSON.stringify({ val: 42 });
|
text = JSON.stringify({ val: 42 });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": json,
|
types: {
|
||||||
},
|
"text/plain": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
});
|
});
|
||||||
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": json,
|
types: {
|
||||||
},
|
"text/html": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div> ${json}</div>`,
|
types: {
|
||||||
},
|
"text/html": `<div> ${json}</div>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
types: {
|
||||||
},
|
"text/html": `<img src="https://example.com/image.png" />`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
|
|||||||
]);
|
]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
types: {
|
||||||
},
|
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
types: {
|
||||||
},
|
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": `a b
|
types: {
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `a b
|
types: {
|
||||||
1 2
|
"text/html": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<html>
|
types: {
|
||||||
<body>
|
"text/html": `<html>
|
||||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
<body>
|
||||||
</body>
|
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||||
</html>`,
|
</body>
|
||||||
"text/plain": `a b
|
</html>`,
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
isMemberOf,
|
isMemberOf,
|
||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
|
EVENT,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
@@ -16,15 +17,26 @@ import {
|
|||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ExcalidrawError } from "./errors";
|
import { ExcalidrawError } from "./errors";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
import {
|
||||||
|
createFile,
|
||||||
|
getFileHandle,
|
||||||
|
isSupportedImageFileType,
|
||||||
|
normalizeFile,
|
||||||
|
} from "./data/blob";
|
||||||
|
|
||||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
|
|
||||||
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
@@ -92,7 +104,7 @@ export const createPasteEvent = ({
|
|||||||
console.warn("createPasteEvent: no types or files provided");
|
console.warn("createPasteEvent: no types or files provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
const event = new ClipboardEvent(EVENT.PASTE, {
|
||||||
clipboardData: new DataTransfer(),
|
clipboardData: new DataTransfer(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,10 +113,11 @@ export const createPasteEvent = ({
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
files = files || [];
|
files = files || [];
|
||||||
files.push(value);
|
files.push(value);
|
||||||
|
event.clipboardData?.items.add(value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.items.add(value, type);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||||
}
|
}
|
||||||
@@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLDataItem = (
|
||||||
event: ClipboardEvent,
|
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
const html = dataItem.value;
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
@@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
|
|||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
||||||
|
|
||||||
|
const mixedContent =
|
||||||
|
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
||||||
|
|
||||||
if (mixedContent) {
|
if (mixedContent) {
|
||||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
dataList.getData(MIME_TYPES.text) ??
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@@ -354,23 +366,156 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
return {
|
||||||
|
type: "text",
|
||||||
return { type: "text", value: (text || "").trim() };
|
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: "" };
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AllowedParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: string; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItemType<
|
||||||
|
T extends AllowedParsedDataTransferItem["type"],
|
||||||
|
> = AllowedParsedDataTransferItem & { type: T };
|
||||||
|
|
||||||
|
export type ParsedDataTransferFile = Extract<
|
||||||
|
AllowedParsedDataTransferItem,
|
||||||
|
{ kind: "file" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
findByType: typeof findDataTransferItemType;
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
getData: typeof getDataTransferItemData;
|
||||||
|
getFiles: typeof getDataTransferFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDataTransferItemType = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
||||||
|
return (
|
||||||
|
this.find(
|
||||||
|
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const getDataTransferItemData = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
type: T,
|
||||||
|
):
|
||||||
|
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
||||||
|
| null {
|
||||||
|
const item = this.find(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
||||||
|
item.type === type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataTransferFiles = function (
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
): ParsedDataTransferFile[] {
|
||||||
|
return this.filter(
|
||||||
|
(item): item is ParsedDataTransferFile => item.kind === "file",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseDataTransferEvent = async (
|
||||||
|
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||||
|
): Promise<ParsedDataTranferList> => {
|
||||||
|
let items: DataTransferItemList | undefined = undefined;
|
||||||
|
|
||||||
|
if (isClipboardEvent(event)) {
|
||||||
|
items = event.clipboardData?.items;
|
||||||
|
} else {
|
||||||
|
const dragEvent = event;
|
||||||
|
items = dragEvent.dataTransfer?.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItems = (
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(items || []).map(
|
||||||
|
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||||
|
if (item.kind === "file") {
|
||||||
|
let file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const fileHandle = await getFileHandle(item);
|
||||||
|
file = await normalizeFile(file);
|
||||||
|
return {
|
||||||
|
type: file.type,
|
||||||
|
kind: "file",
|
||||||
|
file,
|
||||||
|
fileHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (item.kind === "string") {
|
||||||
|
const { type } = item;
|
||||||
|
let value: string;
|
||||||
|
if ("clipboardData" in event && event.clipboardData) {
|
||||||
|
value = event.clipboardData?.getData(type);
|
||||||
|
} else {
|
||||||
|
value = await new Promise<string>((resolve) => {
|
||||||
|
item.getAsString((str) => resolve(str));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type, kind: "string", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((data): data is ParsedDataTransferItem => data != null);
|
||||||
|
|
||||||
|
return Object.assign(dataItems, {
|
||||||
|
findByType: findDataTransferItemType,
|
||||||
|
getData: getDataTransferItemData,
|
||||||
|
getFiles: getDataTransferFiles,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
event,
|
dataList,
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -519,3 +664,14 @@ const copyTextViaExecCommand = (text: string | null) => {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isClipboardEvent = (
|
||||||
|
event: React.SyntheticEvent | Event,
|
||||||
|
): event is ClipboardEvent => {
|
||||||
|
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
||||||
|
return (
|
||||||
|
event.type === EVENT.PASTE ||
|
||||||
|
event.type === EVENT.COPY ||
|
||||||
|
event.type === EVENT.CUT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -91,3 +91,118 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.compact-action-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
--default-button-size: 2rem;
|
||||||
|
|
||||||
|
.compact-action-button {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
background: var(--mobile-action-button-bg);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(
|
||||||
|
--color-surface-primary-container,
|
||||||
|
var(--mobile-action-button-bg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon {
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
|
||||||
|
background: var(--mobile-action-button-bg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions-island {
|
||||||
|
width: fit-content;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-shape-actions {
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-actions-theme-scope {
|
||||||
|
--button-border: transparent;
|
||||||
|
--button-bg: var(--color-surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.theme--dark .shape-actions-theme-scope {
|
||||||
|
--button-hover-bg: #363541;
|
||||||
|
--button-bg: var(--color-surface-high);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "../..//shortcut";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__title {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.color-picker__heading {
|
.color-picker__heading {
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -22,6 +28,12 @@
|
|||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 11rem;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.color-picker-container--no-top-picks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker__top-picks {
|
.color-picker__top-picks {
|
||||||
@@ -80,6 +92,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__button-background {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
.color-picker__button-outline {
|
.color-picker__button-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -141,6 +163,15 @@
|
|||||||
width: 1.625rem;
|
width: 1.625rem;
|
||||||
height: 1.625rem;
|
height: 1.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.compact-sizing {
|
||||||
|
width: var(--mobile-action-button-size);
|
||||||
|
height: var(--mobile-action-button-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mobile-border {
|
||||||
|
border: 1px solid var(--mobile-color-border);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker__button__hotkey-label {
|
.color-picker__button__hotkey-label {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
||||||
@@ -18,7 +19,12 @@ import { useExcalidrawContainer } from "../App";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
import { slashIcon } from "../icons";
|
import { slashIcon, strokeIcon } from "../icons";
|
||||||
|
import {
|
||||||
|
saveCaretPosition,
|
||||||
|
restoreCaretPosition,
|
||||||
|
temporarilyDisableTextEditorBlur,
|
||||||
|
} from "../../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
@@ -67,6 +73,7 @@ interface ColorPickerProps {
|
|||||||
palette?: ColorPaletteCustom | null;
|
palette?: ColorPaletteCustom | null;
|
||||||
topPicks?: ColorTuple;
|
topPicks?: ColorTuple;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPickerPopupContent = ({
|
const ColorPickerPopupContent = ({
|
||||||
@@ -77,6 +84,8 @@ const ColorPickerPopupContent = ({
|
|||||||
elements,
|
elements,
|
||||||
palette = COLOR_PALETTE,
|
palette = COLOR_PALETTE,
|
||||||
updateData,
|
updateData,
|
||||||
|
getOpenPopup,
|
||||||
|
appState,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
ColorPickerProps,
|
ColorPickerProps,
|
||||||
| "type"
|
| "type"
|
||||||
@@ -86,7 +95,10 @@ const ColorPickerPopupContent = ({
|
|||||||
| "elements"
|
| "elements"
|
||||||
| "palette"
|
| "palette"
|
||||||
| "updateData"
|
| "updateData"
|
||||||
>) => {
|
| "appState"
|
||||||
|
> & {
|
||||||
|
getOpenPopup: () => AppState["openPopup"];
|
||||||
|
}) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
@@ -117,9 +129,13 @@ const ColorPickerPopupContent = ({
|
|||||||
<PropertiesPopover
|
<PropertiesPopover
|
||||||
container={container}
|
container={container}
|
||||||
style={{ maxWidth: "13rem" }}
|
style={{ maxWidth: "13rem" }}
|
||||||
|
// Improve focus handling for text editing scenarios
|
||||||
|
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
// refocus due to eye dropper
|
// refocus due to eye dropper
|
||||||
focusPickerContent();
|
if (!isWritableElement(event.target)) {
|
||||||
|
focusPickerContent();
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={(event) => {
|
onPointerDownOutside={(event) => {
|
||||||
@@ -131,8 +147,23 @@ const ColorPickerPopupContent = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
updateData({ openPopup: null });
|
// only clear if we're still the active popup (avoid racing with switch)
|
||||||
|
if (getOpenPopup() === type) {
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
}
|
||||||
setActiveColorPickerSection(null);
|
setActiveColorPickerSection(null);
|
||||||
|
|
||||||
|
// Refocus text editor when popover closes if we were editing text
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{palette ? (
|
{palette ? (
|
||||||
@@ -141,7 +172,17 @@ const ColorPickerPopupContent = ({
|
|||||||
palette={palette}
|
palette={palette}
|
||||||
color={color}
|
color={color}
|
||||||
onChange={(changedColor) => {
|
onChange={(changedColor) => {
|
||||||
|
// Save caret position before color change if editing text
|
||||||
|
const savedSelection = appState.editingTextElement
|
||||||
|
? saveCaretPosition()
|
||||||
|
: null;
|
||||||
|
|
||||||
onChange(changedColor);
|
onChange(changedColor);
|
||||||
|
|
||||||
|
// Restore caret position after color change if editing text
|
||||||
|
if (appState.editingTextElement && savedSelection) {
|
||||||
|
restoreCaretPosition(savedSelection);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onEyeDropperToggle={(force) => {
|
onEyeDropperToggle={(force) => {
|
||||||
setEyeDropperState((state) => {
|
setEyeDropperState((state) => {
|
||||||
@@ -168,12 +209,18 @@ const ColorPickerPopupContent = ({
|
|||||||
if (eyeDropperState) {
|
if (eyeDropperState) {
|
||||||
setEyeDropperState(null);
|
setEyeDropperState(null);
|
||||||
} else {
|
} else {
|
||||||
|
// close explicitly on Escape
|
||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
type={type}
|
type={type}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
showTitle={
|
||||||
|
appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile"
|
||||||
|
}
|
||||||
|
showHotKey={appState.stylesPanelMode !== "mobile"}
|
||||||
>
|
>
|
||||||
{colorInputJSX}
|
{colorInputJSX}
|
||||||
</Picker>
|
</Picker>
|
||||||
@@ -188,11 +235,32 @@ const ColorPickerTrigger = ({
|
|||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
type,
|
type,
|
||||||
|
stylesPanelMode,
|
||||||
|
mode = "background",
|
||||||
|
onToggle,
|
||||||
|
editingTextElement,
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
|
stylesPanelMode?: AppState["stylesPanelMode"];
|
||||||
|
mode?: "background" | "stroke";
|
||||||
|
onToggle: () => void;
|
||||||
|
editingTextElement?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// use pointerdown so we run before outside-close logic
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// If editing text, temporarily disable the wysiwyg blur event
|
||||||
|
if (editingTextElement) {
|
||||||
|
temporarilyDisableTextEditorBlur();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
@@ -200,6 +268,9 @@ const ColorPickerTrigger = ({
|
|||||||
"is-transparent": !color || color === "transparent",
|
"is-transparent": !color || color === "transparent",
|
||||||
"has-outline":
|
"has-outline":
|
||||||
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
||||||
|
"compact-sizing":
|
||||||
|
stylesPanelMode === "compact" || stylesPanelMode === "mobile",
|
||||||
|
"mobile-border": stylesPanelMode === "mobile",
|
||||||
})}
|
})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={color ? { "--swatch-color": color } : undefined}
|
style={color ? { "--swatch-color": color } : undefined}
|
||||||
@@ -208,8 +279,26 @@ const ColorPickerTrigger = ({
|
|||||||
? t("labels.showStroke")
|
? t("labels.showStroke")
|
||||||
: t("labels.showBackground")
|
: t("labels.showBackground")
|
||||||
}
|
}
|
||||||
|
data-openpopup={type}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||||
|
{(stylesPanelMode === "compact" || stylesPanelMode === "mobile") &&
|
||||||
|
color &&
|
||||||
|
mode === "stroke" && (
|
||||||
|
<div className="color-picker__button-background">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||||
|
? "#fff"
|
||||||
|
: "#111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strokeIcon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -225,24 +314,62 @@ export const ColorPicker = ({
|
|||||||
updateData,
|
updateData,
|
||||||
appState,
|
appState,
|
||||||
}: ColorPickerProps) => {
|
}: ColorPickerProps) => {
|
||||||
|
const openRef = useRef(appState.openPopup);
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = appState.openPopup;
|
||||||
|
}, [appState.openPopup]);
|
||||||
|
const compactMode =
|
||||||
|
type !== "canvasBackground" &&
|
||||||
|
(appState.stylesPanelMode === "compact" ||
|
||||||
|
appState.stylesPanelMode === "mobile");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
<div
|
||||||
<TopPicks
|
role="dialog"
|
||||||
activeColor={color}
|
aria-modal="true"
|
||||||
onChange={onChange}
|
className={clsx("color-picker-container", {
|
||||||
type={type}
|
"color-picker-container--no-top-picks": compactMode,
|
||||||
topPicks={topPicks}
|
})}
|
||||||
/>
|
>
|
||||||
<ButtonSeparator />
|
{!compactMode && (
|
||||||
|
<TopPicks
|
||||||
|
activeColor={color}
|
||||||
|
onChange={onChange}
|
||||||
|
type={type}
|
||||||
|
topPicks={topPicks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!compactMode && <ButtonSeparator />}
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={appState.openPopup === type}
|
open={appState.openPopup === type}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
updateData({ openPopup: open ? type : null });
|
if (open) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* serves as an active color indicator as well */}
|
{/* serves as an active color indicator as well */}
|
||||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
<ColorPickerTrigger
|
||||||
|
color={color}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
stylesPanelMode={appState.stylesPanelMode}
|
||||||
|
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||||
|
editingTextElement={!!appState.editingTextElement}
|
||||||
|
onToggle={() => {
|
||||||
|
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||||
|
if (appState.openPopup === type) {
|
||||||
|
// toggle off on same trigger
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
} else if (appState.openPopup) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
} else {
|
||||||
|
// open this one
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* popup content */}
|
{/* popup content */}
|
||||||
{appState.openPopup === type && (
|
{appState.openPopup === type && (
|
||||||
<ColorPickerPopupContent
|
<ColorPickerPopupContent
|
||||||
@@ -253,6 +380,8 @@ export const ColorPicker = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
getOpenPopup={() => openRef.current}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ interface PickerProps {
|
|||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
showTitle?: boolean;
|
||||||
onEyeDropperToggle: (force?: boolean) => void;
|
onEyeDropperToggle: (force?: boolean) => void;
|
||||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||||
|
showHotKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Picker = React.forwardRef(
|
export const Picker = React.forwardRef(
|
||||||
@@ -51,11 +53,21 @@ export const Picker = React.forwardRef(
|
|||||||
palette,
|
palette,
|
||||||
updateData,
|
updateData,
|
||||||
children,
|
children,
|
||||||
|
showTitle,
|
||||||
onEyeDropperToggle,
|
onEyeDropperToggle,
|
||||||
onEscape,
|
onEscape,
|
||||||
|
showHotKey = true,
|
||||||
}: PickerProps,
|
}: PickerProps,
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const title = showTitle
|
||||||
|
? type === "elementStroke"
|
||||||
|
? t("labels.stroke")
|
||||||
|
: type === "elementBackground"
|
||||||
|
? t("labels.background")
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
const [customColors] = React.useState(() => {
|
const [customColors] = React.useState(() => {
|
||||||
if (type === "canvasBackground") {
|
if (type === "canvasBackground") {
|
||||||
return [];
|
return [];
|
||||||
@@ -154,6 +166,8 @@ export const Picker = React.forwardRef(
|
|||||||
// to allow focusing by clicking but not by tabbing
|
// to allow focusing by clicking but not by tabbing
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
{title && <div className="color-picker__title">{title}</div>}
|
||||||
|
|
||||||
{!!customColors.length && (
|
{!!customColors.length && (
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>
|
<PickerHeading>
|
||||||
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef(
|
|||||||
palette={palette}
|
palette={palette}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
activeShade={activeShade}
|
activeShade={activeShade}
|
||||||
|
showHotKey={showHotKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||||
<ShadeList color={color} onChange={onChange} palette={palette} />
|
<ShadeList
|
||||||
|
color={color}
|
||||||
|
onChange={onChange}
|
||||||
|
palette={palette}
|
||||||
|
showHotKey={showHotKey}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface PickerColorListProps {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
activeShade: number;
|
activeShade: number;
|
||||||
|
showHotKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PickerColorList = ({
|
const PickerColorList = ({
|
||||||
@@ -27,6 +28,7 @@ const PickerColorList = ({
|
|||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
activeShade,
|
activeShade,
|
||||||
|
showHotKey = true,
|
||||||
}: PickerColorListProps) => {
|
}: PickerColorListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromColor({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
color,
|
color,
|
||||||
@@ -82,7 +84,7 @@ const PickerColorList = ({
|
|||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline" />
|
<div className="color-picker__button-outline" />
|
||||||
<HotkeyLabel color={color} keyLabel={keybinding} />
|
{showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ interface ShadeListProps {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
|
showHotKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
export const ShadeList = ({
|
||||||
|
color,
|
||||||
|
onChange,
|
||||||
|
palette,
|
||||||
|
showHotKey,
|
||||||
|
}: ShadeListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromColor({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
color: color || "transparent",
|
color: color || "transparent",
|
||||||
palette,
|
palette,
|
||||||
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline" />
|
<div className="color-picker__button-outline" />
|
||||||
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
{showHotKey && (
|
||||||
|
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,6 +100,19 @@ $verticalBreakpoint: 861px;
|
|||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
--icon-size: 1rem;
|
||||||
|
|
||||||
|
&.command-item-large {
|
||||||
|
height: 2.75rem;
|
||||||
|
--icon-size: 1.5rem;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
margin-right: 0.625rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--color-surface-low);
|
background-color: var(--color-surface-low);
|
||||||
}
|
}
|
||||||
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 16px;
|
width: var(--icon-size, 1rem);
|
||||||
height: 16px;
|
height: var(--icon-size, 1rem);
|
||||||
margin-right: 6px;
|
margin-right: 0.375rem;
|
||||||
|
|
||||||
|
.library-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import fuzzy from "fuzzy";
|
import fuzzy from "fuzzy";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
EVENT,
|
EVENT,
|
||||||
KEYS,
|
KEYS,
|
||||||
capitalizeString,
|
capitalizeString,
|
||||||
getShortcutKey,
|
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
||||||
|
|
||||||
|
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||||
|
|
||||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable";
|
|||||||
|
|
||||||
import { Ellipsify } from "../Ellipsify";
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import {
|
||||||
|
distributeLibraryItemsOnSquareGrid,
|
||||||
|
libraryItemsAtom,
|
||||||
|
} from "../../data/library";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useLibraryCache,
|
||||||
|
useLibraryItemSvg,
|
||||||
|
} from "../../hooks/useLibraryItemSvg";
|
||||||
|
|
||||||
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
|
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
import type { AppProps, AppState, UIAppState } from "../../types";
|
import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types";
|
||||||
import type { ShortcutName } from "../../actions/shortcuts";
|
import type { ShortcutName } from "../../actions/shortcuts";
|
||||||
import type { TranslationKeys } from "../../i18n";
|
import type { TranslationKeys } from "../../i18n";
|
||||||
import type { Action } from "../../actions/types";
|
import type { Action } from "../../actions/types";
|
||||||
@@ -80,6 +90,7 @@ export const DEFAULT_CATEGORIES = {
|
|||||||
editor: "Editor",
|
editor: "Editor",
|
||||||
elements: "Elements",
|
elements: "Elements",
|
||||||
links: "Links",
|
links: "Links",
|
||||||
|
library: "Library",
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryOrder = (category: string) => {
|
const getCategoryOrder = (category: string) => {
|
||||||
@@ -207,6 +218,34 @@ function CommandPaletteInner({
|
|||||||
appProps,
|
appProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
|
const libraryCommands: CommandPaletteItem[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
libraryItemsData.libraryItems
|
||||||
|
?.filter(
|
||||||
|
(libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
|
||||||
|
!!libraryItem.name,
|
||||||
|
)
|
||||||
|
.map((libraryItem) => ({
|
||||||
|
label: libraryItem.name,
|
||||||
|
icon: (
|
||||||
|
<LibraryItemIcon
|
||||||
|
id={libraryItem.id}
|
||||||
|
elements={libraryItem.elements}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
category: "Library",
|
||||||
|
order: getCategoryOrder("Library"),
|
||||||
|
haystack: deburr(libraryItem.name),
|
||||||
|
perform: () => {
|
||||||
|
app.onInsertElements(
|
||||||
|
distributeLibraryItemsOnSquareGrid([libraryItem]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}, [app, libraryItemsData.libraryItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// these props change often and we don't want them to re-run the effect
|
// these props change often and we don't want them to re-run the effect
|
||||||
// which would renew `allCommands`, cascading down and resetting state.
|
// which would renew `allCommands`, cascading down and resetting state.
|
||||||
@@ -438,7 +477,6 @@ function CommandPaletteInner({
|
|||||||
},
|
},
|
||||||
perform: () => {
|
perform: () => {
|
||||||
setAppState((prevState) => ({
|
setAppState((prevState) => ({
|
||||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
|
||||||
openPopup: "elementStroke",
|
openPopup: "elementStroke",
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -458,7 +496,6 @@ function CommandPaletteInner({
|
|||||||
},
|
},
|
||||||
perform: () => {
|
perform: () => {
|
||||||
setAppState((prevState) => ({
|
setAppState((prevState) => ({
|
||||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
|
||||||
openPopup: "elementBackground",
|
openPopup: "elementBackground",
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -588,8 +625,9 @@ function CommandPaletteInner({
|
|||||||
|
|
||||||
setAllCommands(allCommands);
|
setAllCommands(allCommands);
|
||||||
setLastUsed(
|
setLastUsed(
|
||||||
allCommands.find((command) => command.label === lastUsed?.label) ??
|
[...allCommands, ...libraryCommands].find(
|
||||||
null,
|
(command) => command.label === lastUsed?.label,
|
||||||
|
) ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -600,6 +638,7 @@ function CommandPaletteInner({
|
|||||||
lastUsed?.label,
|
lastUsed?.label,
|
||||||
setLastUsed,
|
setLastUsed,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
libraryCommands,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [commandSearch, setCommandSearch] = useState("");
|
const [commandSearch, setCommandSearch] = useState("");
|
||||||
@@ -796,9 +835,17 @@ function CommandPaletteInner({
|
|||||||
return nextCommandsByCategory;
|
return nextCommandsByCategory;
|
||||||
};
|
};
|
||||||
|
|
||||||
let matchingCommands = allCommands
|
let matchingCommands =
|
||||||
.filter(isCommandAvailable)
|
commandSearch?.length > 1
|
||||||
.sort((a, b) => a.order - b.order);
|
? [
|
||||||
|
...allCommands
|
||||||
|
.filter(isCommandAvailable)
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
...libraryCommands,
|
||||||
|
]
|
||||||
|
: allCommands
|
||||||
|
.filter(isCommandAvailable)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
const showLastUsed =
|
const showLastUsed =
|
||||||
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
||||||
@@ -822,14 +869,20 @@ function CommandPaletteInner({
|
|||||||
);
|
);
|
||||||
matchingCommands = fuzzy
|
matchingCommands = fuzzy
|
||||||
.filter(_query, matchingCommands, {
|
.filter(_query, matchingCommands, {
|
||||||
extract: (command) => command.haystack,
|
extract: (command) => command.haystack ?? "",
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.map((item) => item.original);
|
.map((item) => item.original);
|
||||||
|
|
||||||
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
||||||
setCurrentCommand(matchingCommands[0] ?? null);
|
setCurrentCommand(matchingCommands[0] ?? null);
|
||||||
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
|
}, [
|
||||||
|
commandSearch,
|
||||||
|
allCommands,
|
||||||
|
isCommandAvailable,
|
||||||
|
lastUsed,
|
||||||
|
libraryCommands,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -904,6 +957,7 @@ function CommandPaletteInner({
|
|||||||
onMouseMove={() => setCurrentCommand(command)}
|
onMouseMove={() => setCurrentCommand(command)}
|
||||||
showShortcut={!app.device.viewport.isMobile}
|
showShortcut={!app.device.viewport.isMobile}
|
||||||
appState={uiAppState}
|
appState={uiAppState}
|
||||||
|
size={category === "Library" ? "large" : "small"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -919,6 +973,20 @@ function CommandPaletteInner({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const LibraryItemIcon = ({
|
||||||
|
id,
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
id: LibraryItem["id"] | null;
|
||||||
|
elements: LibraryItem["elements"] | undefined;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { svgCache } = useLibraryCache();
|
||||||
|
|
||||||
|
useLibraryItemSvg(id, elements, svgCache, ref);
|
||||||
|
|
||||||
|
return <div className="library-item-icon" ref={ref} />;
|
||||||
|
};
|
||||||
|
|
||||||
const CommandItem = ({
|
const CommandItem = ({
|
||||||
command,
|
command,
|
||||||
@@ -928,6 +996,7 @@ const CommandItem = ({
|
|||||||
onClick,
|
onClick,
|
||||||
showShortcut,
|
showShortcut,
|
||||||
appState,
|
appState,
|
||||||
|
size = "small",
|
||||||
}: {
|
}: {
|
||||||
command: CommandPaletteItem;
|
command: CommandPaletteItem;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
@@ -936,6 +1005,7 @@ const CommandItem = ({
|
|||||||
onClick: (event: React.MouseEvent) => void;
|
onClick: (event: React.MouseEvent) => void;
|
||||||
showShortcut: boolean;
|
showShortcut: boolean;
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
|
size?: "small" | "large";
|
||||||
}) => {
|
}) => {
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
@@ -944,6 +1014,7 @@ const CommandItem = ({
|
|||||||
className={clsx("command-item", {
|
className={clsx("command-item", {
|
||||||
"item-selected": isSelected,
|
"item-selected": isSelected,
|
||||||
"item-disabled": disabled,
|
"item-disabled": disabled,
|
||||||
|
"command-item-large": size === "large",
|
||||||
})}
|
})}
|
||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
if (isSelected && !disabled) {
|
if (isSelected && !disabled) {
|
||||||
@@ -959,6 +1030,8 @@ const CommandItem = ({
|
|||||||
<div className="name">
|
<div className="name">
|
||||||
{command.icon && (
|
{command.icon && (
|
||||||
<InlineIcon
|
<InlineIcon
|
||||||
|
className="icon"
|
||||||
|
size="var(--icon-size, 1rem)"
|
||||||
icon={
|
icon={
|
||||||
typeof command.icon === "function"
|
typeof command.icon === "function"
|
||||||
? command.icon(appState)
|
? command.icon(appState)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.context-menu-popover {
|
||||||
|
z-index: var(--zIndex-ui-context-menu);
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const ContextMenu = React.memo(
|
|||||||
offsetTop={appState.offsetTop}
|
offsetTop={appState.offsetTop}
|
||||||
viewportWidth={appState.width}
|
viewportWidth={appState.width}
|
||||||
viewportHeight={appState.height}
|
viewportHeight={appState.height}
|
||||||
|
className="context-menu-popover"
|
||||||
>
|
>
|
||||||
<ul
|
<ul
|
||||||
className="context-menu"
|
className="context-menu"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ExcalidrawLogo {
|
.ExcalidrawLogo {
|
||||||
|
--logo-icon--mobile: 1rem;
|
||||||
|
--logo-text--mobile: 0.75rem;
|
||||||
|
|
||||||
--logo-icon--xs: 2rem;
|
--logo-icon--xs: 2rem;
|
||||||
--logo-text--xs: 1.5rem;
|
--logo-text--xs: 1.5rem;
|
||||||
|
|
||||||
@@ -30,6 +33,17 @@
|
|||||||
color: var(--color-logo-text);
|
color: var(--color-logo-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-mobile {
|
||||||
|
.ExcalidrawLogo-icon {
|
||||||
|
height: var(--logo-icon--mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExcalidrawLogo-text {
|
||||||
|
height: var(--logo-text--mobile);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.is-xs {
|
&.is-xs {
|
||||||
.ExcalidrawLogo-icon {
|
.ExcalidrawLogo-icon {
|
||||||
height: var(--logo-icon--xs);
|
height: var(--logo-icon--xs);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const LogoText = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
type LogoSize = "xs" | "small" | "normal" | "large" | "custom";
|
type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
size?: LogoSize;
|
size?: LogoSize;
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
2rem + 4 * var(--default-button-size)
|
2rem + 4 * var(--default-button-size)
|
||||||
); // 4 gaps + 4 buttons
|
); // 4 gaps + 4 buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
display: block;
|
||||||
|
grid-template-columns: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import clsx from "clsx";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY } from "@excalidraw/common";
|
||||||
@@ -58,6 +59,7 @@ interface FontPickerProps {
|
|||||||
onHover: (fontFamily: FontFamilyValues) => void;
|
onHover: (fontFamily: FontFamilyValues) => void;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
onPopupChange: (open: boolean) => void;
|
onPopupChange: (open: boolean) => void;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPicker = React.memo(
|
export const FontPicker = React.memo(
|
||||||
@@ -69,6 +71,7 @@ export const FontPicker = React.memo(
|
|||||||
onHover,
|
onHover,
|
||||||
onLeave,
|
onLeave,
|
||||||
onPopupChange,
|
onPopupChange,
|
||||||
|
compactMode = false,
|
||||||
}: FontPickerProps) => {
|
}: FontPickerProps) => {
|
||||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||||
const onSelectCallback = useCallback(
|
const onSelectCallback = useCallback(
|
||||||
@@ -81,18 +84,30 @@ export const FontPicker = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
<div
|
||||||
<div className="buttonList">
|
role="dialog"
|
||||||
<RadioSelection<FontFamilyValues | false>
|
aria-modal="true"
|
||||||
type="button"
|
className={clsx("FontPicker__container", {
|
||||||
options={defaultFonts}
|
"FontPicker__container--compact": compactMode,
|
||||||
value={selectedFontFamily}
|
})}
|
||||||
onClick={onSelectCallback}
|
>
|
||||||
/>
|
{!compactMode && (
|
||||||
</div>
|
<div className="buttonList">
|
||||||
<ButtonSeparator />
|
<RadioSelection<FontFamilyValues | false>
|
||||||
|
type="button"
|
||||||
|
options={defaultFonts}
|
||||||
|
value={selectedFontFamily}
|
||||||
|
onClick={onSelectCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!compactMode && <ButtonSeparator />}
|
||||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
<FontPickerTrigger
|
||||||
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
isOpened={isOpened}
|
||||||
|
compactMode={compactMode}
|
||||||
|
/>
|
||||||
{isOpened && (
|
{isOpened && (
|
||||||
<FontPickerList
|
<FontPickerList
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
|
|||||||
onClose,
|
onClose,
|
||||||
}: FontPickerListProps) => {
|
}: FontPickerListProps) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const { fonts } = useApp();
|
const app = useApp();
|
||||||
|
const { fonts } = app;
|
||||||
const { showDeprecatedFonts } = useAppProps();
|
const { showDeprecatedFonts } = useAppProps();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
|
|||||||
onLeave,
|
onLeave,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create a wrapped onSelect function that preserves caret position
|
||||||
|
const wrappedOnSelect = useCallback(
|
||||||
|
(fontFamily: FontFamilyValues) => {
|
||||||
|
// Save caret position before font selection if editing text
|
||||||
|
let savedSelection: { start: number; end: number } | null = null;
|
||||||
|
if (app.state.editingTextElement) {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
savedSelection = {
|
||||||
|
start: textEditor.selectionStart,
|
||||||
|
end: textEditor.selectionEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(fontFamily);
|
||||||
|
|
||||||
|
// Restore caret position after font selection if editing text
|
||||||
|
if (app.state.editingTextElement && savedSelection) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor && savedSelection) {
|
||||||
|
textEditor.focus();
|
||||||
|
textEditor.selectionStart = savedSelection.start;
|
||||||
|
textEditor.selectionEnd = savedSelection.end;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelect, app.state.editingTextElement],
|
||||||
|
);
|
||||||
|
|
||||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||||
(event) => {
|
(event) => {
|
||||||
const handled = fontPickerKeyHandler({
|
const handled = fontPickerKeyHandler({
|
||||||
@@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
|
|||||||
inputRef,
|
inputRef,
|
||||||
hoveredFont,
|
hoveredFont,
|
||||||
filteredFonts,
|
filteredFonts,
|
||||||
onSelect,
|
onSelect: wrappedOnSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
@@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
|
|||||||
// allow to tab between search and selected font
|
// allow to tab between search and selected font
|
||||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onSelect(Number(e.currentTarget.value));
|
wrappedOnSelect(Number(e.currentTarget.value));
|
||||||
}}
|
}}
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
if (hoveredFont?.value !== font.value) {
|
if (hoveredFont?.value !== font.value) {
|
||||||
@@ -282,15 +319,32 @@ export const FontPickerList = React.memo(
|
|||||||
className="properties-content"
|
className="properties-content"
|
||||||
container={container}
|
container={container}
|
||||||
style={{ width: "15rem" }}
|
style={{ width: "15rem" }}
|
||||||
onClose={onClose}
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (app.state.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPointerLeave={onLeave}
|
onPointerLeave={onLeave}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||||
>
|
>
|
||||||
<QuickSearch
|
{app.state.stylesPanelMode === "full" && (
|
||||||
ref={inputRef}
|
<QuickSearch
|
||||||
placeholder={t("quickSearch.placeholder")}
|
ref={inputRef}
|
||||||
onChange={debounce(setSearchTerm, 20)}
|
placeholder={t("quickSearch.placeholder")}
|
||||||
/>
|
onChange={debounce(setSearchTerm, 20)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
className="dropdown-menu fonts manual-hover"
|
className="dropdown-menu fonts manual-hover"
|
||||||
placeholder={t("fontList.empty")}
|
placeholder={t("fontList.empty")}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@@ -7,33 +8,49 @@ import { t } from "../../i18n";
|
|||||||
import { ButtonIcon } from "../ButtonIcon";
|
import { ButtonIcon } from "../ButtonIcon";
|
||||||
import { TextIcon } from "../icons";
|
import { TextIcon } from "../icons";
|
||||||
|
|
||||||
import { isDefaultFont } from "./FontPicker";
|
import { useExcalidrawSetAppState } from "../App";
|
||||||
|
|
||||||
interface FontPickerTriggerProps {
|
interface FontPickerTriggerProps {
|
||||||
selectedFontFamily: FontFamilyValues | null;
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
isOpened?: boolean;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPickerTrigger = ({
|
export const FontPickerTrigger = ({
|
||||||
selectedFontFamily,
|
selectedFontFamily,
|
||||||
|
isOpened = false,
|
||||||
|
compactMode = false,
|
||||||
}: FontPickerTriggerProps) => {
|
}: FontPickerTriggerProps) => {
|
||||||
const isTriggerActive = useMemo(
|
const setAppState = useExcalidrawSetAppState();
|
||||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
|
||||||
[selectedFontFamily],
|
const compactStyle = compactMode
|
||||||
);
|
? {
|
||||||
|
...MOBILE_ACTION_BUTTON_BG,
|
||||||
|
width: "2rem",
|
||||||
|
height: "2rem",
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||||
<div>
|
|
||||||
<ButtonIcon
|
<ButtonIcon
|
||||||
standalone
|
standalone
|
||||||
icon={TextIcon}
|
icon={TextIcon}
|
||||||
title={t("labels.showFonts")}
|
title={t("labels.showFonts")}
|
||||||
className="properties-trigger"
|
className="properties-trigger"
|
||||||
testId={"font-family-show-fonts"}
|
testId={"font-family-show-fonts"}
|
||||||
active={isTriggerActive}
|
active={isOpened}
|
||||||
// no-op
|
onClick={() => {
|
||||||
onClick={() => {}}
|
setAppState((appState) => ({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "fontFamily" ? null : appState.openPopup,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
...compactStyle,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type LockIconProps = {
|
|||||||
export const HandButton = (props: LockIconProps) => {
|
export const HandButton = (props: LockIconProps) => {
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className={clsx("Shape", { fillable: false })}
|
className={clsx("Shape", { fillable: false, active: props.checked })}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={handIcon}
|
icon={handIcon}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React from "react";
|
|||||||
|
|
||||||
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
|
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
|
||||||
|
|
||||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
||||||
|
|||||||
@@ -28,11 +28,24 @@ $wide-viewport-width: 1000px;
|
|||||||
> span {
|
> span {
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 1px;
|
||||||
|
font-family: monospace;
|
||||||
|
border: 1px solid var(--color-gray-40);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
.HintViewer {
|
.HintViewer {
|
||||||
color: var(--color-gray-60);
|
color: var(--color-gray-60);
|
||||||
|
kbd {
|
||||||
|
border-color: var(--color-gray-60);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { getShortcutKey } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import { isNodeInFlowchart } from "@excalidraw/element";
|
import { isNodeInFlowchart } from "@excalidraw/element";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../shortcut";
|
||||||
import { isEraserActive } from "../appState";
|
import { isEraserActive } from "../appState";
|
||||||
import { isGridModeEnabled } from "../snapping";
|
import { isGridModeEnabled } from "../snapping";
|
||||||
|
|
||||||
@@ -28,6 +27,11 @@ interface HintViewerProps {
|
|||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTaggedShortcutKey = (key: string | string[]) =>
|
||||||
|
Array.isArray(key)
|
||||||
|
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
|
||||||
|
: `<kbd>${getShortcutKey(key)}</kbd>`;
|
||||||
|
|
||||||
const getHints = ({
|
const getHints = ({
|
||||||
appState,
|
appState,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -42,7 +46,9 @@ const getHints = ({
|
|||||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||||
appState.searchMatches?.matches.length
|
appState.searchMatches?.matches.length
|
||||||
) {
|
) {
|
||||||
return t("hints.dismissSearch");
|
return t("hints.dismissSearch", {
|
||||||
|
shortcut: getTaggedShortcutKey("Escape"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||||
@@ -50,14 +56,21 @@ const getHints = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
return t("hints.eraserRevert");
|
return t("hints.eraserRevert", {
|
||||||
|
shortcut: getTaggedShortcutKey("Alt"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||||
if (multiMode) {
|
if (multiMode) {
|
||||||
return t("hints.linearElementMulti");
|
return t("hints.linearElementMulti", {
|
||||||
|
shortcut_1: getTaggedShortcutKey("Escape"),
|
||||||
|
shortcut_2: getTaggedShortcutKey("Enter"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (activeTool.type === "arrow") {
|
if (activeTool.type === "arrow") {
|
||||||
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
|
return t("hints.arrowTool", {
|
||||||
|
shortcut: getTaggedShortcutKey("A"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElement");
|
||||||
}
|
}
|
||||||
@@ -83,31 +96,51 @@ const getHints = ({
|
|||||||
) {
|
) {
|
||||||
const targetElement = selectedElements[0];
|
const targetElement = selectedElements[0];
|
||||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return t("hints.lockAngle");
|
return t("hints.lockAngle", {
|
||||||
|
shortcut: getTaggedShortcutKey("Shift"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return isImageElement(targetElement)
|
return isImageElement(targetElement)
|
||||||
? t("hints.resizeImage")
|
? t("hints.resizeImage", {
|
||||||
: t("hints.resize");
|
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||||
|
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||||
|
})
|
||||||
|
: t("hints.resize", {
|
||||||
|
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||||
|
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRotating && lastPointerDownWith === "mouse") {
|
if (isRotating && lastPointerDownWith === "mouse") {
|
||||||
return t("hints.rotate");
|
return t("hints.rotate", {
|
||||||
|
shortcut: getTaggedShortcutKey("Shift"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||||
return t("hints.text_selected");
|
return t("hints.text_selected", {
|
||||||
|
shortcut: getTaggedShortcutKey("Enter"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.editingTextElement) {
|
if (appState.editingTextElement) {
|
||||||
return t("hints.text_editing");
|
return t("hints.text_editing", {
|
||||||
|
shortcut_1: getTaggedShortcutKey("Escape"),
|
||||||
|
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.croppingElementId) {
|
if (appState.croppingElementId) {
|
||||||
return t("hints.leaveCropEditor");
|
return t("hints.leaveCropEditor", {
|
||||||
|
shortcut_1: getTaggedShortcutKey("Enter"),
|
||||||
|
shortcut_2: getTaggedShortcutKey("Escape"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||||
return t("hints.enterCropEditor");
|
return t("hints.enterCropEditor", {
|
||||||
|
shortcut: getTaggedShortcutKey("Enter"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "selection") {
|
if (activeTool.type === "selection") {
|
||||||
@@ -117,33 +150,57 @@ const getHints = ({
|
|||||||
!appState.editingTextElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.selectedLinearElement?.isEditing
|
!appState.selectedLinearElement?.isEditing
|
||||||
) {
|
) {
|
||||||
return [t("hints.deepBoxSelect")];
|
return t("hints.deepBoxSelect", {
|
||||||
|
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||||
return t("hints.disableSnapping");
|
return t("hints.disableSnapping", {
|
||||||
|
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedElements.length && !isMobile) {
|
if (!selectedElements.length && !isMobile) {
|
||||||
return [t("hints.canvasPanning")];
|
return t("hints.canvasPanning", {
|
||||||
|
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
|
||||||
|
shortcut_2: getTaggedShortcutKey("Space"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
if (isLinearElement(selectedElements[0])) {
|
if (isLinearElement(selectedElements[0])) {
|
||||||
if (appState.selectedLinearElement?.isEditing) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return appState.selectedLinearElement.selectedPointsIndices
|
return appState.selectedLinearElement.selectedPointsIndices
|
||||||
? t("hints.lineEditor_pointSelected")
|
? t("hints.lineEditor_pointSelected", {
|
||||||
: t("hints.lineEditor_nothingSelected");
|
shortcut_1: getTaggedShortcutKey("Delete"),
|
||||||
|
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
|
||||||
|
})
|
||||||
|
: t("hints.lineEditor_nothingSelected", {
|
||||||
|
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||||
|
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return isLineElement(selectedElements[0])
|
return isLineElement(selectedElements[0])
|
||||||
? t("hints.lineEditor_line_info")
|
? t("hints.lineEditor_line_info", {
|
||||||
: t("hints.lineEditor_info");
|
shortcut: getTaggedShortcutKey("Enter"),
|
||||||
|
})
|
||||||
|
: t("hints.lineEditor_info", {
|
||||||
|
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
|
||||||
|
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!appState.newElement &&
|
!appState.newElement &&
|
||||||
!appState.selectedElementsAreBeingDragged &&
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
isTextBindableContainer(selectedElements[0])
|
isTextBindableContainer(selectedElements[0])
|
||||||
) {
|
) {
|
||||||
|
const bindTextToElement = t("hints.bindTextToElement", {
|
||||||
|
shortcut: getTaggedShortcutKey("Enter"),
|
||||||
|
});
|
||||||
|
const createFlowchart = t("hints.createFlowchart", {
|
||||||
|
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
|
||||||
|
});
|
||||||
if (isFlowchartNodeElement(selectedElements[0])) {
|
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||||
if (
|
if (
|
||||||
isNodeInFlowchart(
|
isNodeInFlowchart(
|
||||||
@@ -151,13 +208,13 @@ const getHints = ({
|
|||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
return [bindTextToElement, createFlowchart];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
return [bindTextToElement, createFlowchart];
|
||||||
}
|
}
|
||||||
|
|
||||||
return t("hints.bindTextToElement");
|
return bindTextToElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,16 +240,21 @@ export const HintViewer = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hint = Array.isArray(hints)
|
const hint = Array.isArray(hints)
|
||||||
? hints
|
? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
|
||||||
.map((hint) => {
|
: hints;
|
||||||
return getShortcutKey(hint).replace(/\. ?$/, "");
|
|
||||||
})
|
const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
|
||||||
.join(". ")
|
if (index % 2 === 1) {
|
||||||
: getShortcutKey(hints);
|
const shortcutMatch =
|
||||||
|
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
|
||||||
|
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
<span>{hint}</span>
|
<span>{hintJSX}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
|
|||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
|
|
||||||
import Collapsible from "./Stats/Collapsible";
|
import Collapsible from "./Stats/Collapsible";
|
||||||
import { useDevice } from "./App";
|
import { useDevice, useExcalidrawContainer } from "./App";
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ function Picker<T>({
|
|||||||
numberOfOptionsToAlwaysShow?: number;
|
numberOfOptionsToAlwaysShow?: number;
|
||||||
}) {
|
}) {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
const pressedOption = options.find(
|
const pressedOption = options.find(
|
||||||
@@ -152,17 +153,16 @@ function Picker<T>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMobile = device.editor.isMobile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
side={
|
side={isMobile ? "right" : "bottom"}
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
|
||||||
? "top"
|
|
||||||
: "bottom"
|
|
||||||
}
|
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={12}
|
sideOffset={isMobile ? 8 : 12}
|
||||||
style={{ zIndex: "var(--zIndex-popup)" }}
|
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
collisionBoundary={container ?? undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`picker`}
|
className={`picker`}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
export const InlineIcon = ({
|
||||||
|
className,
|
||||||
|
icon,
|
||||||
|
size = "1em",
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
size?: string;
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
className={className}
|
||||||
style={{
|
style={{
|
||||||
width: "1em",
|
width: size,
|
||||||
|
height: "100%",
|
||||||
margin: "0 0.5ex 0 0.5ex",
|
margin: "0 0.5ex 0 0.5ex",
|
||||||
display: "inline-block",
|
display: "inline-flex",
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
|
MQ_MIN_WIDTH_DESKTOP,
|
||||||
TOOL_TYPE,
|
TOOL_TYPE,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
capitalizeString,
|
capitalizeString,
|
||||||
@@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
|
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import {
|
||||||
|
SelectedShapeActions,
|
||||||
|
ShapesSwitcher,
|
||||||
|
CompactShapeActions,
|
||||||
|
} from "./Actions";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@@ -86,6 +91,7 @@ interface LayerUIProps {
|
|||||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
|
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
@@ -144,6 +150,7 @@ const LayerUI = ({
|
|||||||
onHandToolToggle,
|
onHandToolToggle,
|
||||||
onPenModeToggle,
|
onPenModeToggle,
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
|
renderTopLeftUI,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
@@ -157,6 +164,25 @@ const LayerUI = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
|
const spacing =
|
||||||
|
appState.stylesPanelMode === "compact"
|
||||||
|
? {
|
||||||
|
menuTopGap: 4,
|
||||||
|
toolbarColGap: 4,
|
||||||
|
toolbarRowGap: 1,
|
||||||
|
toolbarInnerRowGap: 0.5,
|
||||||
|
islandPadding: 1,
|
||||||
|
collabMarginLeft: 8,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
menuTopGap: 6,
|
||||||
|
toolbarColGap: 4,
|
||||||
|
toolbarRowGap: 1,
|
||||||
|
toolbarInnerRowGap: 1,
|
||||||
|
islandPadding: 1,
|
||||||
|
collabMarginLeft: 8,
|
||||||
|
};
|
||||||
|
|
||||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
@@ -209,31 +235,55 @@ const LayerUI = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderSelectedShapeActions = () => (
|
const renderSelectedShapeActions = () => {
|
||||||
<Section
|
const isCompactMode = appState.stylesPanelMode === "compact";
|
||||||
heading="selectedShapeActions"
|
|
||||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
return (
|
||||||
"transition-left": appState.zenModeEnabled,
|
<Section
|
||||||
})}
|
heading="selectedShapeActions"
|
||||||
>
|
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||||
<Island
|
"transition-left": appState.zenModeEnabled,
|
||||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
})}
|
||||||
padding={2}
|
|
||||||
style={{
|
|
||||||
// we want to make sure this doesn't overflow so subtracting the
|
|
||||||
// approximate height of hamburgerMenu + footer
|
|
||||||
maxHeight: `${appState.height - 166}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectedShapeActions
|
{isCompactMode ? (
|
||||||
appState={appState}
|
<Island
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
className={clsx("compact-shape-actions-island")}
|
||||||
renderAction={actionManager.renderAction}
|
padding={0}
|
||||||
app={app}
|
style={{
|
||||||
/>
|
// we want to make sure this doesn't overflow so subtracting the
|
||||||
</Island>
|
// approximate height of hamburgerMenu + footer
|
||||||
</Section>
|
maxHeight: `${appState.height - 166}px`,
|
||||||
);
|
}}
|
||||||
|
>
|
||||||
|
<CompactShapeActions
|
||||||
|
appState={appState}
|
||||||
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
app={app}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
) : (
|
||||||
|
<Island
|
||||||
|
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||||
|
padding={2}
|
||||||
|
style={{
|
||||||
|
// we want to make sure this doesn't overflow so subtracting the
|
||||||
|
// approximate height of hamburgerMenu + footer
|
||||||
|
maxHeight: `${appState.height - 166}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectedShapeActions
|
||||||
|
appState={appState}
|
||||||
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
app={app}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
@@ -250,9 +300,19 @@ const LayerUI = ({
|
|||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
<Stack.Col
|
||||||
|
gap={spacing.menuTopGap}
|
||||||
|
className={clsx("App-menu_top__left")}
|
||||||
|
>
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
<div
|
||||||
|
className={clsx("selected-shape-actions-container", {
|
||||||
|
"selected-shape-actions-container--compact":
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
|
</div>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||||
@@ -262,17 +322,19 @@ const LayerUI = ({
|
|||||||
{renderWelcomeScreen && (
|
{renderWelcomeScreen && (
|
||||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||||
)}
|
)}
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={spacing.toolbarColGap} align="start">
|
||||||
<Stack.Row
|
<Stack.Row
|
||||||
gap={1}
|
gap={spacing.toolbarRowGap}
|
||||||
className={clsx("App-toolbar-container", {
|
className={clsx("App-toolbar-container", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={spacing.islandPadding}
|
||||||
className={clsx("App-toolbar", {
|
className={clsx("App-toolbar", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
|
"App-toolbar--compact":
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
@@ -282,7 +344,7 @@ const LayerUI = ({
|
|||||||
app={app}
|
app={app}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={spacing.toolbarInnerRowGap}>
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
zenModeEnabled={appState.zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
@@ -306,7 +368,7 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
appState={appState}
|
setAppState={setAppState}
|
||||||
activeTool={appState.activeTool}
|
activeTool={appState.activeTool}
|
||||||
UIOptions={UIOptions}
|
UIOptions={UIOptions}
|
||||||
app={app}
|
app={app}
|
||||||
@@ -316,7 +378,7 @@ const LayerUI = ({
|
|||||||
{isCollaborating && (
|
{isCollaborating && (
|
||||||
<Island
|
<Island
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 8,
|
marginLeft: spacing.collabMarginLeft,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
height: "fit-content",
|
height: "fit-content",
|
||||||
}}
|
}}
|
||||||
@@ -344,6 +406,8 @@ const LayerUI = ({
|
|||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right": appState.zenModeEnabled,
|
"transition-right": appState.zenModeEnabled,
|
||||||
|
"layer-ui__wrapper__top-right--compact":
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -418,7 +482,9 @@ const LayerUI = ({
|
|||||||
}}
|
}}
|
||||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||||
>
|
>
|
||||||
{t("toolBar.library")}
|
{appState.stylesPanelMode === "full" &&
|
||||||
|
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
|
||||||
|
t("toolBar.library")}
|
||||||
</DefaultSidebar.Trigger>
|
</DefaultSidebar.Trigger>
|
||||||
<DefaultOverwriteConfirmDialog />
|
<DefaultOverwriteConfirmDialog />
|
||||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||||
@@ -518,13 +584,11 @@ const LayerUI = ({
|
|||||||
renderJSONExportDialog={renderJSONExportDialog}
|
renderJSONExportDialog={renderJSONExportDialog}
|
||||||
renderImageExportDialog={renderImageExportDialog}
|
renderImageExportDialog={renderImageExportDialog}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onLockToggle={onLockToggle}
|
|
||||||
onHandToolToggle={onHandToolToggle}
|
onHandToolToggle={onHandToolToggle}
|
||||||
onPenModeToggle={onPenModeToggle}
|
onPenModeToggle={onPenModeToggle}
|
||||||
|
renderTopLeftUI={renderTopLeftUI}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderCustomStats={renderCustomStats}
|
|
||||||
renderSidebars={renderSidebars}
|
renderSidebars={renderSidebars}
|
||||||
device={device}
|
|
||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
UIOptions={UIOptions}
|
UIOptions={UIOptions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -133,15 +133,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__library .library-menu-dropdown-container {
|
.layer-ui__library .library-menu-dropdown-container {
|
||||||
|
z-index: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&--in-heading {
|
&--in-heading {
|
||||||
padding: 0;
|
margin-left: auto;
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
top: 100%;
|
top: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
LIBRARY_DISABLED_TYPES,
|
LIBRARY_DISABLED_TYPES,
|
||||||
randomId,
|
randomId,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
|
KEYS,
|
||||||
|
isWritableElement,
|
||||||
|
addEventListener,
|
||||||
|
EVENT,
|
||||||
|
CLASSES,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -266,11 +271,52 @@ export const LibraryMenu = memo(() => {
|
|||||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
||||||
const pendingElements = usePendingElementsMemo(appState, app);
|
const pendingElements = usePendingElementsMemo(appState, app);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return addEventListener(
|
||||||
|
document,
|
||||||
|
EVENT.KEYDOWN,
|
||||||
|
(event) => {
|
||||||
|
if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) {
|
||||||
|
const target = event.target;
|
||||||
|
if (target.closest(`.${CLASSES.SIDEBAR}`)) {
|
||||||
|
// stop propagation so that we don't prevent it downstream
|
||||||
|
// (default browser behavior is to clear search input on ESC)
|
||||||
|
if (selectedItems.length > 0) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedItems([]);
|
||||||
|
} else if (
|
||||||
|
isWritableElement(target) &&
|
||||||
|
target instanceof HTMLInputElement &&
|
||||||
|
!target.value
|
||||||
|
) {
|
||||||
|
event.stopPropagation();
|
||||||
|
// if search input empty -> close library
|
||||||
|
// (maybe not a good idea?)
|
||||||
|
setAppState({ openSidebar: null });
|
||||||
|
app.focusContainer();
|
||||||
|
}
|
||||||
|
} else if (selectedItems.length > 0) {
|
||||||
|
const { x, y } = app.lastViewportPosition;
|
||||||
|
const elementUnderCursor = document.elementFromPoint(x, y);
|
||||||
|
// also deselect elements if sidebar doesn't have focus but the
|
||||||
|
// cursor is over it
|
||||||
|
if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedItems([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
}, [selectedItems, setAppState, app]);
|
||||||
|
|
||||||
const onInsertLibraryItems = useCallback(
|
const onInsertLibraryItems = useCallback(
|
||||||
(libraryItems: LibraryItems) => {
|
(libraryItems: LibraryItems) => {
|
||||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||||
|
app.focusContainer();
|
||||||
},
|
},
|
||||||
[onInsertElements],
|
[onInsertElements, app],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
const deselectItems = useCallback(() => {
|
||||||
|
|||||||
@@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
{t("buttons.export")}
|
{t("buttons.export")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
)}
|
)}
|
||||||
{!!items.length && (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => setShowRemoveLibAlert(true)}
|
|
||||||
icon={TrashIcon}
|
|
||||||
>
|
|
||||||
{resetLabel}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
)}
|
|
||||||
{itemsSelected && (
|
{itemsSelected && (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
icon={publishIcon}
|
icon={publishIcon}
|
||||||
@@ -237,6 +229,14 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
{t("buttons.publishLibrary")}
|
{t("buttons.publishLibrary")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
)}
|
)}
|
||||||
|
{!!items.length && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => setShowRemoveLibAlert(true)}
|
||||||
|
icon={TrashIcon}
|
||||||
|
>
|
||||||
|
{resetLabel}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--container-padding-y: 1.5rem;
|
--container-padding-y: 1rem;
|
||||||
--container-padding-x: 0.75rem;
|
--container-padding-x: 0.75rem;
|
||||||
|
|
||||||
|
.library-menu-items-header {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.library-menu-items__no-items {
|
.library-menu-items__no-items {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 55px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-menu-items__no-items__hint {
|
||||||
|
color: var(--color-border-outline);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
.library-menu-items__no-items {
|
.library-menu-items__no-items {
|
||||||
color: var(--color-gray-40);
|
color: var(--color-gray-40);
|
||||||
@@ -34,7 +52,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -51,26 +69,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__items {
|
&__items {
|
||||||
|
// so that spinner is relative-positioned to this container
|
||||||
|
position: relative;
|
||||||
|
|
||||||
row-gap: 0.5rem;
|
row-gap: 0.5rem;
|
||||||
padding: var(--container-padding-y) 0;
|
padding: 1rem 0 var(--container-padding-y) 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: 4rem; // due to dropdown button
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&--excal {
|
&--excal {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-border-outline);
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font-family: monospace;
|
||||||
|
border: 1px solid var(--color-border-outline);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__grid {
|
&__grid {
|
||||||
@@ -79,6 +116,24 @@
|
|||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.ExcTextField__input {
|
||||||
|
height: var(--lg-button-size);
|
||||||
|
input {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hideCancelButton input::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { MIME_TYPES, arrayToMap } from "@excalidraw/common";
|
import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common";
|
||||||
|
|
||||||
import { duplicateElements } from "@excalidraw/element";
|
import { duplicateElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { serializeLibraryAsJSON } from "../data/json";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { deburr } from "../deburr";
|
||||||
|
|
||||||
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
||||||
import { useScrollPosition } from "../hooks/useScrollPosition";
|
import { useScrollPosition } from "../hooks/useScrollPosition";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -27,6 +30,14 @@ import Stack from "./Stack";
|
|||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
|
|
||||||
|
import { TextField } from "./TextField";
|
||||||
|
|
||||||
|
import { useDevice } from "./App";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
import type { ExcalidrawLibraryIds } from "../data/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
@@ -64,6 +75,7 @@ export default function LibraryMenuItems({
|
|||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const device = useDevice();
|
||||||
const libraryContainerRef = useRef<HTMLDivElement>(null);
|
const libraryContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
|
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
|
||||||
|
|
||||||
@@ -75,6 +87,30 @@ export default function LibraryMenuItems({
|
|||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const { svgCache } = useLibraryCache();
|
const { svgCache } = useLibraryCache();
|
||||||
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
|
LibraryItem["id"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const [searchInputValue, setSearchInputValue] = useState("");
|
||||||
|
|
||||||
|
const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length;
|
||||||
|
|
||||||
|
const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim();
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const searchQuery = deburr(searchInputValue.trim().toLowerCase());
|
||||||
|
if (!searchQuery) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return libraryItems.filter((item) => {
|
||||||
|
const itemName = item.name || "";
|
||||||
|
return (
|
||||||
|
itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [libraryItems, searchInputValue]);
|
||||||
|
|
||||||
const unpublishedItems = useMemo(
|
const unpublishedItems = useMemo(
|
||||||
() => libraryItems.filter((item) => item.status !== "published"),
|
() => libraryItems.filter((item) => item.status !== "published"),
|
||||||
[libraryItems],
|
[libraryItems],
|
||||||
@@ -85,23 +121,10 @@ export default function LibraryMenuItems({
|
|||||||
[libraryItems],
|
[libraryItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showBtn = !libraryItems.length && !pendingElements.length;
|
|
||||||
|
|
||||||
const isLibraryEmpty =
|
|
||||||
!pendingElements.length &&
|
|
||||||
!unpublishedItems.length &&
|
|
||||||
!publishedItems.length;
|
|
||||||
|
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
|
||||||
LibraryItem["id"] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const onItemSelectToggle = useCallback(
|
const onItemSelectToggle = useCallback(
|
||||||
(id: LibraryItem["id"], event: React.MouseEvent) => {
|
(id: LibraryItem["id"], event: React.MouseEvent) => {
|
||||||
const shouldSelect = !selectedItems.includes(id);
|
const shouldSelect = !selectedItems.includes(id);
|
||||||
|
|
||||||
const orderedItems = [...unpublishedItems, ...publishedItems];
|
const orderedItems = [...unpublishedItems, ...publishedItems];
|
||||||
|
|
||||||
if (shouldSelect) {
|
if (shouldSelect) {
|
||||||
if (event.shiftKey && lastSelectedItem) {
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
const rangeStart = orderedItems.findIndex(
|
const rangeStart = orderedItems.findIndex(
|
||||||
@@ -115,10 +138,13 @@ export default function LibraryMenuItems({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedItemsMap = arrayToMap(selectedItems);
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
|
// Support both top-down and bottom-up selection by using min/max
|
||||||
|
const minRange = Math.min(rangeStart, rangeEnd);
|
||||||
|
const maxRange = Math.max(rangeStart, rangeEnd);
|
||||||
const nextSelectedIds = orderedItems.reduce(
|
const nextSelectedIds = orderedItems.reduce(
|
||||||
(acc: LibraryItem["id"][], item, idx) => {
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
if (
|
if (
|
||||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
(idx >= minRange && idx <= maxRange) ||
|
||||||
selectedItemsMap.has(item.id)
|
selectedItemsMap.has(item.id)
|
||||||
) {
|
) {
|
||||||
acc.push(item.id);
|
acc.push(item.id);
|
||||||
@@ -127,7 +153,6 @@ export default function LibraryMenuItems({
|
|||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
onSelectItems(nextSelectedIds);
|
onSelectItems(nextSelectedIds);
|
||||||
} else {
|
} else {
|
||||||
onSelectItems([...selectedItems, id]);
|
onSelectItems([...selectedItems, id]);
|
||||||
@@ -147,6 +172,14 @@ export default function LibraryMenuItems({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// if selection is removed (e.g. via esc), reset last selected item
|
||||||
|
// so that subsequent shift+clicks don't select a large range
|
||||||
|
if (!selectedItems.length) {
|
||||||
|
setLastSelectedItem(null);
|
||||||
|
}
|
||||||
|
}, [selectedItems]);
|
||||||
|
|
||||||
const getInsertedElements = useCallback(
|
const getInsertedElements = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
let targetElements;
|
let targetElements;
|
||||||
@@ -175,12 +208,17 @@ export default function LibraryMenuItems({
|
|||||||
|
|
||||||
const onItemDrag = useCallback(
|
const onItemDrag = useCallback(
|
||||||
(id: LibraryItem["id"], event: React.DragEvent) => {
|
(id: LibraryItem["id"], event: React.DragEvent) => {
|
||||||
|
// we want to serialize just the ids so the operation is fast and there's
|
||||||
|
// no race condition if people drop the library items on canvas too fast
|
||||||
|
const data: ExcalidrawLibraryIds = {
|
||||||
|
itemIds: selectedItems.includes(id) ? selectedItems : [id],
|
||||||
|
};
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
MIME_TYPES.excalidrawlib,
|
MIME_TYPES.excalidrawlibIds,
|
||||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
JSON.stringify(data),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[getInsertedElements],
|
[selectedItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isItemSelected = useCallback(
|
const isItemSelected = useCallback(
|
||||||
@@ -188,7 +226,6 @@ export default function LibraryMenuItems({
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedItems.includes(id);
|
return selectedItems.includes(id);
|
||||||
},
|
},
|
||||||
[selectedItems],
|
[selectedItems],
|
||||||
@@ -208,10 +245,136 @@ export default function LibraryMenuItems({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const itemsRenderedPerBatch =
|
const itemsRenderedPerBatch =
|
||||||
svgCache.size >= libraryItems.length
|
svgCache.size >=
|
||||||
|
(filteredItems.length ? filteredItems : libraryItems).length
|
||||||
? CACHED_ITEMS_RENDERED_PER_BATCH
|
? CACHED_ITEMS_RENDERED_PER_BATCH
|
||||||
: ITEMS_RENDERED_PER_BATCH;
|
: ITEMS_RENDERED_PER_BATCH;
|
||||||
|
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// focus could be stolen by tab trigger button
|
||||||
|
nextAnimationFrame(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const JSX_whenNotSearching = !IS_SEARCHING && (
|
||||||
|
<>
|
||||||
|
{!IS_LIBRARY_EMPTY && (
|
||||||
|
<div className="library-menu-items-container__header">
|
||||||
|
{t("labels.personalLib")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!pendingElements.length && !unpublishedItems.length ? (
|
||||||
|
<div className="library-menu-items__no-items">
|
||||||
|
{!publishedItems.length && (
|
||||||
|
<div className="library-menu-items__no-items__label">
|
||||||
|
{t("library.noItems")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="library-menu-items__no-items__hint">
|
||||||
|
{publishedItems.length > 0
|
||||||
|
? t("library.hint_emptyPrivateLibrary")
|
||||||
|
: t("library.hint_emptyLibrary")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LibraryMenuSectionGrid>
|
||||||
|
{pendingElements.length > 0 && (
|
||||||
|
<LibraryMenuSection
|
||||||
|
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||||
|
items={[{ id: null, elements: pendingElements }]}
|
||||||
|
onItemSelectToggle={onItemSelectToggle}
|
||||||
|
onItemDrag={onItemDrag}
|
||||||
|
onClick={onAddToLibraryClick}
|
||||||
|
isItemSelected={isItemSelected}
|
||||||
|
svgCache={svgCache}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LibraryMenuSection
|
||||||
|
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||||
|
items={unpublishedItems}
|
||||||
|
onItemSelectToggle={onItemSelectToggle}
|
||||||
|
onItemDrag={onItemDrag}
|
||||||
|
onClick={onItemClick}
|
||||||
|
isItemSelected={isItemSelected}
|
||||||
|
svgCache={svgCache}
|
||||||
|
/>
|
||||||
|
</LibraryMenuSectionGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{publishedItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="library-menu-items-container__header"
|
||||||
|
style={{ marginTop: "0.75rem" }}
|
||||||
|
>
|
||||||
|
{t("labels.excalidrawLib")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{publishedItems.length > 0 && (
|
||||||
|
<LibraryMenuSectionGrid>
|
||||||
|
<LibraryMenuSection
|
||||||
|
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||||
|
items={publishedItems}
|
||||||
|
onItemSelectToggle={onItemSelectToggle}
|
||||||
|
onItemDrag={onItemDrag}
|
||||||
|
onClick={onItemClick}
|
||||||
|
isItemSelected={isItemSelected}
|
||||||
|
svgCache={svgCache}
|
||||||
|
/>
|
||||||
|
</LibraryMenuSectionGrid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const JSX_whenSearching = IS_SEARCHING && (
|
||||||
|
<>
|
||||||
|
<div className="library-menu-items-container__header">
|
||||||
|
{t("library.search.heading")}
|
||||||
|
{!isLoading && (
|
||||||
|
<div
|
||||||
|
className="library-menu-items-container__header__hint"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(event) => {
|
||||||
|
setSearchInputValue("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<kbd>esc</kbd> to clear
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filteredItems.length > 0 ? (
|
||||||
|
<LibraryMenuSectionGrid>
|
||||||
|
<LibraryMenuSection
|
||||||
|
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||||
|
items={filteredItems}
|
||||||
|
onItemSelectToggle={onItemSelectToggle}
|
||||||
|
onItemDrag={onItemDrag}
|
||||||
|
onClick={onItemClick}
|
||||||
|
isItemSelected={isItemSelected}
|
||||||
|
svgCache={svgCache}
|
||||||
|
/>
|
||||||
|
</LibraryMenuSectionGrid>
|
||||||
|
) : (
|
||||||
|
<div className="library-menu-items__no-items">
|
||||||
|
<div className="library-menu-items__no-items__hint">
|
||||||
|
{t("library.search.noResults")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
|
onSelect={() => {
|
||||||
|
setSearchInputValue("");
|
||||||
|
}}
|
||||||
|
style={{ width: "auto", marginTop: "1rem" }}
|
||||||
|
>
|
||||||
|
{t("library.search.clearSearch")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="library-menu-items-container"
|
className="library-menu-items-container"
|
||||||
@@ -223,127 +386,58 @@ export default function LibraryMenuItems({
|
|||||||
: { borderBottom: 0 }
|
: { borderBottom: 0 }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isLibraryEmpty && (
|
<div className="library-menu-items-header">
|
||||||
|
{!IS_LIBRARY_EMPTY && (
|
||||||
|
<TextField
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="search"
|
||||||
|
className={clsx("library-menu-items-container__search", {
|
||||||
|
hideCancelButton: !device.editor.isMobile,
|
||||||
|
})}
|
||||||
|
placeholder={t("library.search.inputPlaceholder")}
|
||||||
|
value={searchInputValue}
|
||||||
|
onChange={(value) => setSearchInputValue(value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<LibraryDropdownMenu
|
<LibraryDropdownMenu
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={onSelectItems}
|
onSelectItems={onSelectItems}
|
||||||
className="library-menu-dropdown-container--in-heading"
|
className="library-menu-dropdown-container--in-heading"
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
className="library-menu-items-container__items"
|
className="library-menu-items-container__items"
|
||||||
align="start"
|
align="start"
|
||||||
gap={1}
|
gap={1}
|
||||||
style={{
|
style={{
|
||||||
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
||||||
marginBottom: 0,
|
margin: IS_LIBRARY_EMPTY ? "auto" : 0,
|
||||||
}}
|
}}
|
||||||
ref={libraryContainerRef}
|
ref={libraryContainerRef}
|
||||||
>
|
>
|
||||||
<>
|
{isLoading && (
|
||||||
{!isLibraryEmpty && (
|
<div
|
||||||
<div className="library-menu-items-container__header">
|
style={{
|
||||||
{t("labels.personalLib")}
|
position: "absolute",
|
||||||
</div>
|
top: "var(--container-padding-y)",
|
||||||
)}
|
right: "var(--container-padding-x)",
|
||||||
{isLoading && (
|
transform: "translateY(50%)",
|
||||||
<div
|
}}
|
||||||
style={{
|
>
|
||||||
position: "absolute",
|
<Spinner />
|
||||||
top: "var(--container-padding-y)",
|
</div>
|
||||||
right: "var(--container-padding-x)",
|
)}
|
||||||
transform: "translateY(50%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!pendingElements.length && !unpublishedItems.length ? (
|
|
||||||
<div className="library-menu-items__no-items">
|
|
||||||
<div className="library-menu-items__no-items__label">
|
|
||||||
{t("library.noItems")}
|
|
||||||
</div>
|
|
||||||
<div className="library-menu-items__no-items__hint">
|
|
||||||
{publishedItems.length > 0
|
|
||||||
? t("library.hint_emptyPrivateLibrary")
|
|
||||||
: t("library.hint_emptyLibrary")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LibraryMenuSectionGrid>
|
|
||||||
{pendingElements.length > 0 && (
|
|
||||||
<LibraryMenuSection
|
|
||||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
|
||||||
items={[{ id: null, elements: pendingElements }]}
|
|
||||||
onItemSelectToggle={onItemSelectToggle}
|
|
||||||
onItemDrag={onItemDrag}
|
|
||||||
onClick={onAddToLibraryClick}
|
|
||||||
isItemSelected={isItemSelected}
|
|
||||||
svgCache={svgCache}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<LibraryMenuSection
|
|
||||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
|
||||||
items={unpublishedItems}
|
|
||||||
onItemSelectToggle={onItemSelectToggle}
|
|
||||||
onItemDrag={onItemDrag}
|
|
||||||
onClick={onItemClick}
|
|
||||||
isItemSelected={isItemSelected}
|
|
||||||
svgCache={svgCache}
|
|
||||||
/>
|
|
||||||
</LibraryMenuSectionGrid>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
|
|
||||||
<>
|
{JSX_whenNotSearching}
|
||||||
{(publishedItems.length > 0 ||
|
{JSX_whenSearching}
|
||||||
pendingElements.length > 0 ||
|
|
||||||
unpublishedItems.length > 0) && (
|
|
||||||
<div className="library-menu-items-container__header library-menu-items-container__header--excal">
|
|
||||||
{t("labels.excalidrawLib")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{publishedItems.length > 0 ? (
|
|
||||||
<LibraryMenuSectionGrid>
|
|
||||||
<LibraryMenuSection
|
|
||||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
|
||||||
items={publishedItems}
|
|
||||||
onItemSelectToggle={onItemSelectToggle}
|
|
||||||
onItemDrag={onItemDrag}
|
|
||||||
onClick={onItemClick}
|
|
||||||
isItemSelected={isItemSelected}
|
|
||||||
svgCache={svgCache}
|
|
||||||
/>
|
|
||||||
</LibraryMenuSectionGrid>
|
|
||||||
) : unpublishedItems.length > 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: "1rem 0",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: ".9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("library.noItems")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
|
|
||||||
{showBtn && (
|
{IS_LIBRARY_EMPTY && (
|
||||||
<LibraryMenuControlButtons
|
<LibraryMenuControlButtons
|
||||||
style={{ padding: "16px 0", width: "100%" }}
|
style={{ padding: "16px 0", width: "100%" }}
|
||||||
id={id}
|
id={id}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
>
|
/>
|
||||||
<LibraryDropdownMenu
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
onSelectItems={onSelectItems}
|
|
||||||
/>
|
|
||||||
</LibraryMenuControlButtons>
|
|
||||||
)}
|
)}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg";
|
|||||||
import type { LibraryItem } from "../types";
|
import type { LibraryItem } from "../types";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
type LibraryOrPendingItem = (
|
type LibraryOrPendingItem = readonly (
|
||||||
| LibraryItem
|
| LibraryItem
|
||||||
| /* pending library item */ {
|
| /* pending library item */ {
|
||||||
id: null;
|
id: null;
|
||||||
|
|||||||
@@ -18,12 +18,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--hover {
|
&--hover {
|
||||||
border-color: var(--color-primary);
|
background-color: var(--color-surface-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:active:not(:has(.library-unit__checkbox:hover)),
|
||||||
&--selected {
|
&--selected {
|
||||||
border-color: var(--color-primary);
|
background-color: var(--color-surface-high);
|
||||||
border-width: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--skeleton {
|
&--skeleton {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { memo, useEffect, useRef, useState } from "react";
|
import { memo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
|
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
|
||||||
|
|
||||||
@@ -33,23 +33,7 @@ export const LibraryUnit = memo(
|
|||||||
svgCache: SvgCache;
|
svgCache: SvgCache;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const svg = useLibraryItemSvg(id, elements, svgCache);
|
const svg = useLibraryItemSvg(id, elements, svgCache, ref);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const node = ref.current;
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (svg) {
|
|
||||||
node.innerHTML = svg.outerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
node.innerHTML = "";
|
|
||||||
};
|
|
||||||
}, [svg]);
|
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDevice().editor.isMobile;
|
const isMobile = useDevice().editor.isMobile;
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { isHandToolActive } from "../appState";
|
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
|
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { MobileShapeActions } from "./Actions";
|
||||||
|
import { MobileToolBar } from "./MobileToolBar";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { HandButton } from "./HandButton";
|
|
||||||
import { HintViewer } from "./HintViewer";
|
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { LockButton } from "./LockButton";
|
|
||||||
import { PenModeButton } from "./PenModeButton";
|
|
||||||
import { Section } from "./Section";
|
|
||||||
import Stack from "./Stack";
|
|
||||||
|
|
||||||
import type { ActionManager } from "../actions/manager";
|
import type { ActionManager } from "../actions/manager";
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppProps,
|
AppProps,
|
||||||
AppState,
|
AppState,
|
||||||
Device,
|
|
||||||
ExcalidrawProps,
|
|
||||||
UIAppState,
|
UIAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
@@ -38,7 +29,6 @@ type MobileMenuProps = {
|
|||||||
renderImageExportDialog: () => React.ReactNode;
|
renderImageExportDialog: () => React.ReactNode;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onLockToggle: () => void;
|
|
||||||
onHandToolToggle: () => void;
|
onHandToolToggle: () => void;
|
||||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||||
|
|
||||||
@@ -46,9 +36,11 @@ type MobileMenuProps = {
|
|||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
renderTopLeftUI?: (
|
||||||
|
isMobile: boolean,
|
||||||
|
appState: UIAppState,
|
||||||
|
) => JSX.Element | null;
|
||||||
renderSidebars: () => JSX.Element | null;
|
renderSidebars: () => JSX.Element | null;
|
||||||
device: Device;
|
|
||||||
renderWelcomeScreen: boolean;
|
renderWelcomeScreen: boolean;
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
@@ -59,14 +51,10 @@ export const MobileMenu = ({
|
|||||||
elements,
|
elements,
|
||||||
actionManager,
|
actionManager,
|
||||||
setAppState,
|
setAppState,
|
||||||
onLockToggle,
|
|
||||||
onHandToolToggle,
|
onHandToolToggle,
|
||||||
onPenModeToggle,
|
renderTopLeftUI,
|
||||||
|
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomStats,
|
|
||||||
renderSidebars,
|
renderSidebars,
|
||||||
device,
|
|
||||||
renderWelcomeScreen,
|
renderWelcomeScreen,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
app,
|
app,
|
||||||
@@ -76,141 +64,98 @@ export const MobileMenu = ({
|
|||||||
MainMenuTunnel,
|
MainMenuTunnel,
|
||||||
DefaultSidebarTriggerTunnel,
|
DefaultSidebarTriggerTunnel,
|
||||||
} = useTunnels();
|
} = useTunnels();
|
||||||
const renderToolbar = () => {
|
const renderAppTopBar = () => {
|
||||||
return (
|
const topRightUI = renderTopRightUI?.(true, appState) ?? (
|
||||||
<FixedSideContainer side="top" className="App-top-bar">
|
<DefaultSidebarTriggerTunnel.Out />
|
||||||
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
);
|
||||||
<Section heading="shapes">
|
|
||||||
{(heading: React.ReactNode) => (
|
const topLeftUI = (
|
||||||
<Stack.Col gap={4} align="center">
|
<div className="excalidraw-ui-top-left">
|
||||||
<Stack.Row gap={1} className="App-toolbar-container">
|
{renderTopLeftUI?.(true, appState)}
|
||||||
<Island padding={1} className="App-toolbar App-toolbar--mobile">
|
<MainMenuTunnel.Out />
|
||||||
{heading}
|
</div>
|
||||||
<Stack.Row gap={1}>
|
|
||||||
<ShapesSwitcher
|
|
||||||
appState={appState}
|
|
||||||
activeTool={appState.activeTool}
|
|
||||||
UIOptions={UIOptions}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
</Island>
|
|
||||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
|
||||||
<div className="mobile-misc-tools-container">
|
|
||||||
{!appState.viewModeEnabled &&
|
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
|
||||||
<DefaultSidebarTriggerTunnel.Out />
|
|
||||||
)}
|
|
||||||
<PenModeButton
|
|
||||||
checked={appState.penMode}
|
|
||||||
onChange={() => onPenModeToggle(null)}
|
|
||||||
title={t("toolBar.penMode")}
|
|
||||||
isMobile
|
|
||||||
penDetected={appState.penDetected}
|
|
||||||
/>
|
|
||||||
<LockButton
|
|
||||||
checked={appState.activeTool.locked}
|
|
||||||
onChange={onLockToggle}
|
|
||||||
title={t("toolBar.lock")}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
<HandButton
|
|
||||||
checked={isHandToolActive(appState)}
|
|
||||||
onChange={() => onHandToolToggle()}
|
|
||||||
title={t("toolBar.hand")}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Stack.Row>
|
|
||||||
</Stack.Col>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
<HintViewer
|
|
||||||
appState={appState}
|
|
||||||
isMobile={true}
|
|
||||||
device={device}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
</FixedSideContainer>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const renderAppToolbar = () => {
|
|
||||||
if (
|
if (
|
||||||
appState.viewModeEnabled ||
|
appState.viewModeEnabled ||
|
||||||
appState.openDialog?.name === "elementLinkSelector"
|
appState.openDialog?.name === "elementLinkSelector"
|
||||||
) {
|
) {
|
||||||
return (
|
return <div className="App-toolbar-content">{topLeftUI}</div>;
|
||||||
<div className="App-toolbar-content">
|
|
||||||
<MainMenuTunnel.Out />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App-toolbar-content">
|
<div
|
||||||
<MainMenuTunnel.Out />
|
className="App-toolbar-content"
|
||||||
{actionManager.renderAction("toggleEditMenu")}
|
style={{
|
||||||
{actionManager.renderAction(
|
display: "flex",
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
flexDirection: "row",
|
||||||
)}
|
justifyContent: "space-between",
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
}}
|
||||||
<div>
|
>
|
||||||
{actionManager.renderAction("undo")}
|
{topLeftUI}
|
||||||
{actionManager.renderAction("redo")}
|
{topRightUI}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderToolbar = () => {
|
||||||
|
return (
|
||||||
|
<MobileToolBar
|
||||||
|
app={app}
|
||||||
|
onHandToolToggle={onHandToolToggle}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderSidebars()}
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled &&
|
{/* welcome screen, bottom bar, and top bar all have the same z-index */}
|
||||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
{/* ordered in this reverse order so that top bar is on top */}
|
||||||
renderToolbar()}
|
<div className="App-welcome-screen">
|
||||||
|
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
|
||||||
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
|
||||||
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Island padding={0}>
|
<MobileShapeActions
|
||||||
{appState.openMenu === "shape" &&
|
appState={appState}
|
||||||
!appState.viewModeEnabled &&
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
renderAction={actionManager.renderAction}
|
||||||
showSelectedShapeActions(appState, elements) ? (
|
app={app}
|
||||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
setAppState={setAppState}
|
||||||
<SelectedShapeActions
|
/>
|
||||||
appState={appState}
|
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
<Island className="App-toolbar">
|
||||||
renderAction={actionManager.renderAction}
|
{!appState.viewModeEnabled &&
|
||||||
app={app}
|
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||||
/>
|
renderToolbar()}
|
||||||
</Section>
|
{appState.scrolledOutside &&
|
||||||
) : null}
|
!appState.openMenu &&
|
||||||
<footer className="App-toolbar">
|
!appState.openSidebar && (
|
||||||
{renderAppToolbar()}
|
<button
|
||||||
{appState.scrolledOutside &&
|
type="button"
|
||||||
!appState.openMenu &&
|
className="scroll-back-to-content"
|
||||||
!appState.openSidebar && (
|
onClick={() => {
|
||||||
<button
|
setAppState((appState) => ({
|
||||||
type="button"
|
...calculateScrollCenter(elements, appState),
|
||||||
className="scroll-back-to-content"
|
}));
|
||||||
onClick={() => {
|
}}
|
||||||
setAppState((appState) => ({
|
>
|
||||||
...calculateScrollCenter(elements, appState),
|
{t("buttons.scrollBackToContent")}
|
||||||
}));
|
</button>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
{t("buttons.scrollBackToContent")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
</Island>
|
</Island>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FixedSideContainer side="top" className="App-top-bar">
|
||||||
|
{renderAppTopBar()}
|
||||||
|
</FixedSideContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@import "open-color/open-color.scss";
|
||||||
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.mobile-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0px;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar .ToolIcon {
|
||||||
|
min-width: 2rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(
|
||||||
|
--color-surface-primary-container,
|
||||||
|
var(--island-bg-color)
|
||||||
|
);
|
||||||
|
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar .App-toolbar__extra-tools-dropdown {
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--default-border-color);
|
||||||
|
margin: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar-undo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolbar-undo .ToolIcon {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { KEYS, capitalizeString } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { isHandToolActive } from "../appState";
|
||||||
|
|
||||||
|
import { useTunnels } from "../context/tunnels";
|
||||||
|
|
||||||
|
import { HandButton } from "./HandButton";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
|
import { ToolPopover } from "./ToolPopover";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SelectionIcon,
|
||||||
|
FreedrawIcon,
|
||||||
|
EraserIcon,
|
||||||
|
RectangleIcon,
|
||||||
|
ArrowIcon,
|
||||||
|
extraToolsIcon,
|
||||||
|
DiamondIcon,
|
||||||
|
EllipseIcon,
|
||||||
|
LineIcon,
|
||||||
|
TextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
frameToolIcon,
|
||||||
|
EmbedIcon,
|
||||||
|
laserPointerToolIcon,
|
||||||
|
LassoIcon,
|
||||||
|
mermaidLogoIcon,
|
||||||
|
MagicIcon,
|
||||||
|
} from "./icons";
|
||||||
|
|
||||||
|
import "./ToolIcon.scss";
|
||||||
|
import "./MobileToolBar.scss";
|
||||||
|
|
||||||
|
import type { AppClassProperties, ToolType, UIAppState } from "../types";
|
||||||
|
|
||||||
|
const SHAPE_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "rectangle",
|
||||||
|
icon: RectangleIcon,
|
||||||
|
title: capitalizeString(t("toolBar.rectangle")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "diamond",
|
||||||
|
icon: DiamondIcon,
|
||||||
|
title: capitalizeString(t("toolBar.diamond")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ellipse",
|
||||||
|
icon: EllipseIcon,
|
||||||
|
title: capitalizeString(t("toolBar.ellipse")),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SELECTION_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "selection",
|
||||||
|
icon: SelectionIcon,
|
||||||
|
title: capitalizeString(t("toolBar.selection")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "lasso",
|
||||||
|
icon: LassoIcon,
|
||||||
|
title: capitalizeString(t("toolBar.lasso")),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LINEAR_ELEMENT_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "arrow",
|
||||||
|
icon: ArrowIcon,
|
||||||
|
title: capitalizeString(t("toolBar.arrow")),
|
||||||
|
},
|
||||||
|
{ type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type MobileToolBarProps = {
|
||||||
|
app: AppClassProperties;
|
||||||
|
onHandToolToggle: () => void;
|
||||||
|
setAppState: React.Component<any, UIAppState>["setState"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileToolBar = ({
|
||||||
|
app,
|
||||||
|
onHandToolToggle,
|
||||||
|
setAppState,
|
||||||
|
}: MobileToolBarProps) => {
|
||||||
|
const activeTool = app.state.activeTool;
|
||||||
|
const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
|
||||||
|
const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
|
||||||
|
"rectangle" | "diamond" | "ellipse"
|
||||||
|
>("rectangle");
|
||||||
|
const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
|
||||||
|
"arrow" | "line"
|
||||||
|
>("arrow");
|
||||||
|
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// keep lastActiveGenericShape in sync with active tool if user switches via other UI
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
activeTool.type === "rectangle" ||
|
||||||
|
activeTool.type === "diamond" ||
|
||||||
|
activeTool.type === "ellipse"
|
||||||
|
) {
|
||||||
|
setLastActiveGenericShape(activeTool.type);
|
||||||
|
}
|
||||||
|
}, [activeTool.type]);
|
||||||
|
|
||||||
|
// keep lastActiveLinearElement in sync with active tool if user switches via other UI
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||||
|
setLastActiveLinearElement(activeTool.type);
|
||||||
|
}
|
||||||
|
}, [activeTool.type]);
|
||||||
|
|
||||||
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
|
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||||
|
|
||||||
|
const handleToolChange = (toolType: string, pointerType?: string) => {
|
||||||
|
if (app.state.activeTool.type !== toolType) {
|
||||||
|
trackEvent("toolbar", toolType, "ui");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolType === "selection") {
|
||||||
|
if (app.state.activeTool.type === "selection") {
|
||||||
|
// Toggle selection tool behavior if needed
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: "selection" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: toolType as ToolType });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbarWidth =
|
||||||
|
toolbarRef.current?.getBoundingClientRect()?.width ?? 0 - 8;
|
||||||
|
const WIDTH = 36;
|
||||||
|
const GAP = 4;
|
||||||
|
|
||||||
|
// hand, selection, freedraw, eraser, rectangle, arrow, others
|
||||||
|
const MIN_TOOLS = 7;
|
||||||
|
const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP;
|
||||||
|
const ADDITIONAL_WIDTH = WIDTH + GAP;
|
||||||
|
|
||||||
|
const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH;
|
||||||
|
const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
|
||||||
|
const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
|
||||||
|
|
||||||
|
const extraTools = [
|
||||||
|
"text",
|
||||||
|
"frame",
|
||||||
|
"embeddable",
|
||||||
|
"laser",
|
||||||
|
"magicframe",
|
||||||
|
].filter((tool) => {
|
||||||
|
if (showImageToolOutside && tool === "image") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (showFrameToolOutside && tool === "frame") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const extraToolSelected = extraTools.includes(activeTool.type);
|
||||||
|
const extraIcon = extraToolSelected
|
||||||
|
? activeTool.type === "frame"
|
||||||
|
? frameToolIcon
|
||||||
|
: activeTool.type === "embeddable"
|
||||||
|
? EmbedIcon
|
||||||
|
: activeTool.type === "laser"
|
||||||
|
? laserPointerToolIcon
|
||||||
|
: activeTool.type === "text"
|
||||||
|
? TextIcon
|
||||||
|
: activeTool.type === "magicframe"
|
||||||
|
? MagicIcon
|
||||||
|
: extraToolsIcon
|
||||||
|
: extraToolsIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-toolbar" ref={toolbarRef}>
|
||||||
|
{/* Hand Tool */}
|
||||||
|
<HandButton
|
||||||
|
checked={isHandToolActive(app.state)}
|
||||||
|
onChange={onHandToolToggle}
|
||||||
|
title={t("toolBar.hand")}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Selection Tool */}
|
||||||
|
<ToolPopover
|
||||||
|
app={app}
|
||||||
|
options={SELECTION_TOOLS}
|
||||||
|
activeTool={activeTool}
|
||||||
|
defaultOption={app.state.preferredSelectionTool.type}
|
||||||
|
namePrefix="selectionType"
|
||||||
|
title={capitalizeString(t("toolBar.selection"))}
|
||||||
|
data-testid="toolbar-selection"
|
||||||
|
onToolChange={(type: string) => {
|
||||||
|
if (type === "selection" || type === "lasso") {
|
||||||
|
app.setActiveTool({ type });
|
||||||
|
setAppState({
|
||||||
|
preferredSelectionTool: { type, initialized: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
displayedOption={
|
||||||
|
SELECTION_TOOLS.find(
|
||||||
|
(tool) => tool.type === app.state.preferredSelectionTool.type,
|
||||||
|
) || SELECTION_TOOLS[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Free Draw */}
|
||||||
|
<ToolButton
|
||||||
|
className={clsx({
|
||||||
|
active: activeTool.type === "freedraw",
|
||||||
|
})}
|
||||||
|
type="radio"
|
||||||
|
icon={FreedrawIcon}
|
||||||
|
checked={activeTool.type === "freedraw"}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.freedraw"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.freedraw"))}
|
||||||
|
data-testid="toolbar-freedraw"
|
||||||
|
onChange={() => handleToolChange("freedraw")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Eraser */}
|
||||||
|
<ToolButton
|
||||||
|
className={clsx({
|
||||||
|
active: activeTool.type === "eraser",
|
||||||
|
})}
|
||||||
|
type="radio"
|
||||||
|
icon={EraserIcon}
|
||||||
|
checked={activeTool.type === "eraser"}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.eraser"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.eraser"))}
|
||||||
|
data-testid="toolbar-eraser"
|
||||||
|
onChange={() => handleToolChange("eraser")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rectangle */}
|
||||||
|
<ToolPopover
|
||||||
|
app={app}
|
||||||
|
options={SHAPE_TOOLS}
|
||||||
|
activeTool={activeTool}
|
||||||
|
defaultOption={lastActiveGenericShape}
|
||||||
|
namePrefix="shapeType"
|
||||||
|
title={capitalizeString(
|
||||||
|
t(
|
||||||
|
lastActiveGenericShape === "rectangle"
|
||||||
|
? "toolBar.rectangle"
|
||||||
|
: lastActiveGenericShape === "diamond"
|
||||||
|
? "toolBar.diamond"
|
||||||
|
: lastActiveGenericShape === "ellipse"
|
||||||
|
? "toolBar.ellipse"
|
||||||
|
: "toolBar.rectangle",
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
data-testid="toolbar-rectangle"
|
||||||
|
onToolChange={(type: string) => {
|
||||||
|
if (
|
||||||
|
type === "rectangle" ||
|
||||||
|
type === "diamond" ||
|
||||||
|
type === "ellipse"
|
||||||
|
) {
|
||||||
|
setLastActiveGenericShape(type);
|
||||||
|
app.setActiveTool({ type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
displayedOption={
|
||||||
|
SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) ||
|
||||||
|
SHAPE_TOOLS[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Arrow/Line */}
|
||||||
|
<ToolPopover
|
||||||
|
app={app}
|
||||||
|
options={LINEAR_ELEMENT_TOOLS}
|
||||||
|
activeTool={activeTool}
|
||||||
|
defaultOption={lastActiveLinearElement}
|
||||||
|
namePrefix="linearElementType"
|
||||||
|
title={capitalizeString(
|
||||||
|
t(
|
||||||
|
lastActiveLinearElement === "arrow"
|
||||||
|
? "toolBar.arrow"
|
||||||
|
: "toolBar.line",
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
data-testid="toolbar-arrow"
|
||||||
|
fillable={true}
|
||||||
|
onToolChange={(type: string) => {
|
||||||
|
if (type === "arrow" || type === "line") {
|
||||||
|
setLastActiveLinearElement(type);
|
||||||
|
app.setActiveTool({ type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
displayedOption={
|
||||||
|
LINEAR_ELEMENT_TOOLS.find(
|
||||||
|
(tool) => tool.type === lastActiveLinearElement,
|
||||||
|
) || LINEAR_ELEMENT_TOOLS[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text Tool */}
|
||||||
|
{showTextToolOutside && (
|
||||||
|
<ToolButton
|
||||||
|
className={clsx({
|
||||||
|
active: activeTool.type === "text",
|
||||||
|
})}
|
||||||
|
type="radio"
|
||||||
|
icon={TextIcon}
|
||||||
|
checked={activeTool.type === "text"}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.text"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.text"))}
|
||||||
|
data-testid="toolbar-text"
|
||||||
|
onChange={() => handleToolChange("text")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
{showImageToolOutside && (
|
||||||
|
<ToolButton
|
||||||
|
className={clsx({
|
||||||
|
active: activeTool.type === "image",
|
||||||
|
})}
|
||||||
|
type="radio"
|
||||||
|
icon={ImageIcon}
|
||||||
|
checked={activeTool.type === "image"}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.image"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.image"))}
|
||||||
|
data-testid="toolbar-image"
|
||||||
|
onChange={() => handleToolChange("image")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Frame Tool */}
|
||||||
|
{showFrameToolOutside && (
|
||||||
|
<ToolButton
|
||||||
|
className={clsx({ active: frameToolSelected })}
|
||||||
|
type="radio"
|
||||||
|
icon={frameToolIcon}
|
||||||
|
checked={frameToolSelected}
|
||||||
|
name="editor-current-shape"
|
||||||
|
title={`${capitalizeString(t("toolBar.frame"))}`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||||
|
data-testid="toolbar-frame"
|
||||||
|
onChange={() => handleToolChange("frame")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other Shapes */}
|
||||||
|
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
className={clsx(
|
||||||
|
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
|
||||||
|
{
|
||||||
|
"App-toolbar__extra-tools-trigger--selected":
|
||||||
|
extraToolSelected || isOtherShapesMenuOpen,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
onToggle={() => {
|
||||||
|
setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen);
|
||||||
|
setAppState({ openMenu: null, openPopup: null });
|
||||||
|
}}
|
||||||
|
title={t("toolBar.extraTools")}
|
||||||
|
style={{
|
||||||
|
width: WIDTH,
|
||||||
|
height: WIDTH,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extraIcon}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
|
||||||
|
onSelect={() => setIsOtherShapesMenuOpen(false)}
|
||||||
|
className="App-toolbar__extra-tools-dropdown"
|
||||||
|
>
|
||||||
|
{!showTextToolOutside && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "text" })}
|
||||||
|
icon={TextIcon}
|
||||||
|
shortcut={KEYS.T.toLocaleUpperCase()}
|
||||||
|
data-testid="toolbar-text"
|
||||||
|
selected={activeTool.type === "text"}
|
||||||
|
>
|
||||||
|
{t("toolBar.text")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showImageToolOutside && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "image" })}
|
||||||
|
icon={ImageIcon}
|
||||||
|
data-testid="toolbar-image"
|
||||||
|
selected={activeTool.type === "image"}
|
||||||
|
>
|
||||||
|
{t("toolBar.image")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
{!showFrameToolOutside && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||||
|
icon={frameToolIcon}
|
||||||
|
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||||
|
data-testid="toolbar-frame"
|
||||||
|
selected={frameToolSelected}
|
||||||
|
>
|
||||||
|
{t("toolBar.frame")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "embeddable" })}
|
||||||
|
icon={EmbedIcon}
|
||||||
|
data-testid="toolbar-embeddable"
|
||||||
|
selected={embeddableToolSelected}
|
||||||
|
>
|
||||||
|
{t("toolBar.embeddable")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setActiveTool({ type: "laser" })}
|
||||||
|
icon={laserPointerToolIcon}
|
||||||
|
data-testid="toolbar-laser"
|
||||||
|
selected={laserToolSelected}
|
||||||
|
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||||
|
>
|
||||||
|
{t("toolBar.laser")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||||
|
Generate
|
||||||
|
</div>
|
||||||
|
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
|
||||||
|
icon={mermaidLogoIcon}
|
||||||
|
data-testid="toolbar-embeddable"
|
||||||
|
>
|
||||||
|
{t("toolBar.mermaidToExcalidraw")}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
|
icon={MagicIcon}
|
||||||
|
data-testid="toolbar-magicframe"
|
||||||
|
>
|
||||||
|
{t("toolBar.magicframe")}
|
||||||
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ import { unstable_batchedUpdates } from "react-dom";
|
|||||||
|
|
||||||
import { KEYS, queryFocusableElements } from "@excalidraw/common";
|
import { KEYS, queryFocusableElements } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
import "./Popover.scss";
|
import "./Popover.scss";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,6 +17,7 @@ type Props = {
|
|||||||
offsetTop?: number;
|
offsetTop?: number;
|
||||||
viewportWidth?: number;
|
viewportWidth?: number;
|
||||||
viewportHeight?: number;
|
viewportHeight?: number;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
@@ -27,6 +30,7 @@ export const Popover = ({
|
|||||||
offsetTop = 0,
|
offsetTop = 0,
|
||||||
viewportWidth = window.innerWidth,
|
viewportWidth = window.innerWidth,
|
||||||
viewportHeight = window.innerHeight,
|
viewportHeight = window.innerHeight,
|
||||||
|
className,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ export const Popover = ({
|
|||||||
}, [onCloseRequest]);
|
}, [onCloseRequest]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="popover" ref={popoverRef} tabIndex={-1}>
|
<div className={clsx("popover", className)} ref={popoverRef} tabIndex={-1}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface PropertiesPopoverProps {
|
|||||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||||
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
|
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
|
||||||
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
|
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
|
||||||
|
preventAutoFocusOnTouch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PropertiesPopover = React.forwardRef<
|
export const PropertiesPopover = React.forwardRef<
|
||||||
@@ -34,10 +35,13 @@ export const PropertiesPopover = React.forwardRef<
|
|||||||
onFocusOutside,
|
onFocusOutside,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
onPointerDownOutside,
|
onPointerDownOutside,
|
||||||
|
preventAutoFocusOnTouch = false,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
|
const isMobilePortrait =
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Portal container={container}>
|
<Popover.Portal container={container}>
|
||||||
@@ -45,25 +49,25 @@ export const PropertiesPopover = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={clsx("focus-visible-none", className)}
|
className={clsx("focus-visible-none", className)}
|
||||||
data-prevent-outside-click
|
data-prevent-outside-click
|
||||||
side={
|
side={isMobilePortrait ? "bottom" : "right"}
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
align={isMobilePortrait ? "center" : "start"}
|
||||||
? "bottom"
|
|
||||||
: "right"
|
|
||||||
}
|
|
||||||
align={
|
|
||||||
device.editor.isMobile && !device.viewport.isLandscape
|
|
||||||
? "center"
|
|
||||||
: "start"
|
|
||||||
}
|
|
||||||
alignOffset={-16}
|
alignOffset={-16}
|
||||||
sideOffset={20}
|
sideOffset={20}
|
||||||
|
collisionBoundary={container ?? undefined}
|
||||||
style={{
|
style={{
|
||||||
zIndex: "var(--zIndex-popup)",
|
zIndex: "var(--zIndex-ui-styles-popup)",
|
||||||
|
marginLeft: device.editor.isMobile ? "0.5rem" : undefined,
|
||||||
}}
|
}}
|
||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocusOutside={onFocusOutside}
|
onFocusOutside={onFocusOutside}
|
||||||
onPointerDownOutside={onPointerDownOutside}
|
onPointerDownOutside={onPointerDownOutside}
|
||||||
|
onOpenAutoFocus={(e) => {
|
||||||
|
// prevent auto-focus on touch devices to avoid keyboard popup
|
||||||
|
if (preventAutoFocusOnTouch && device.isTouchScreen) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// prevents focusing the trigger
|
// prevents focusing the trigger
|
||||||
|
|||||||
@@ -518,7 +518,7 @@ const PublishLibrary = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="publish-library__buttons">
|
<div className="publish-library__buttons">
|
||||||
<DialogActionButton
|
<DialogActionButton
|
||||||
label={t("buttons.cancel")}
|
label={t("buttons.saveLibNames")}
|
||||||
onClick={onDialogClose}
|
onClick={onDialogClose}
|
||||||
data-testid="cancel-clear-canvas-button"
|
data-testid="cancel-clear-canvas-button"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 5;
|
z-index: var(--zIndex-ui-library);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common";
|
import {
|
||||||
|
CLASSES,
|
||||||
|
EVENT,
|
||||||
|
isDevEnv,
|
||||||
|
KEYS,
|
||||||
|
updateObject,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import { atom, useSetAtom } from "../../editor-jotai";
|
import { atom, useSetAtom } from "../../editor-jotai";
|
||||||
@@ -137,7 +143,11 @@ export const SidebarInner = forwardRef(
|
|||||||
return (
|
return (
|
||||||
<Island
|
<Island
|
||||||
{...rest}
|
{...rest}
|
||||||
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
|
className={clsx(
|
||||||
|
CLASSES.SIDEBAR,
|
||||||
|
{ "sidebar--docked": docked },
|
||||||
|
className,
|
||||||
|
)}
|
||||||
ref={islandRef}
|
ref={islandRef}
|
||||||
>
|
>
|
||||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export const SidebarTrigger = ({
|
|||||||
.querySelector(".layer-ui__wrapper")
|
.querySelector(".layer-ui__wrapper")
|
||||||
?.classList.remove("animate");
|
?.classList.remove("animate");
|
||||||
const isOpen = event.target.checked;
|
const isOpen = event.target.checked;
|
||||||
setAppState({ openSidebar: isOpen ? { name, tab } : null });
|
setAppState({
|
||||||
|
openSidebar: isOpen ? { name, tab } : null,
|
||||||
|
openMenu: null,
|
||||||
|
openPopup: null,
|
||||||
|
});
|
||||||
onToggle?.(isOpen);
|
onToggle?.(isOpen);
|
||||||
}}
|
}}
|
||||||
checked={appState.openSidebar?.name === name}
|
checked={appState.openSidebar?.name === name}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user