Compare commits

...

78 Commits

Author SHA1 Message Date
Daniel J. Geiger baf3ab7d81 [Do not review] Use MathJax 4.0.0-beta.3 2023-09-30 11:56:02 -05:00
Daniel J. Geiger ef0fcc1537 refactor: Replace the useSubtypes selection hook with a generic useSubtype hook 2023-09-23 15:54:27 -05:00
Daniel J. Geiger ec26aeead2 refactor: Refactor and add a test 2023-09-22 17:33:34 -05:00
Daniel J. Geiger 62f5475c4a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-22 15:19:21 -05:00
Daniel J. Geiger 7225915b82 fix: 4d6d6cf1 had a line-height regression for sufficiently short math symbols 2023-09-22 14:34:44 -05:00
Daniel J. Geiger 8eb3191b3f refactor: Move MathJax into src/element/subtypes for the
`excalidraw-app` separation, maintaining lazy-loading of MathJax.
2023-09-22 14:25:15 -05:00
Daniel J. Geiger 4d6d6cf129 fix: Text-only measurements off by a pixel 2023-09-22 10:17:51 -05:00
David Luzar f8b3692262 fix: more eye-droper fixes (#7019) 2023-09-21 09:54:03 +05:30
Aakansha Doshi 741d5f1a18 refactor: move excalidraw-app outside src (#6987)
* refactor: move excalidraw-app outside src

* move some tests to excal app and fix some

* fix tests

* fix

* port remaining tests

* fix

* update snap

* move tests inside test folder

* fix

* fix
2023-09-21 09:28:48 +05:30
Daniel J. Geiger 208285b7ba Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-17 15:40:45 -05:00
Daniel J. Geiger 372a4868da chore: Only use transform-origin in the text editor if rendered
dimensions don't match the editor dimensions.
2023-09-15 13:40:46 -05:00
Daniel J. Geiger 05800d8599 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-15 10:52:15 -05:00
Daniel J. Geiger 1f496d9f64 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-11 19:22:29 -05:00
Daniel J. Geiger e0221ddf20 fix: Inform scenes of mutations when a subtype finishes loading. 2023-09-10 16:49:06 -05:00
Daniel J. Geiger 1bd86942f3 refactor: Simplify a file. 2023-09-10 16:47:29 -05:00
Daniel J. Geiger fd9a172da9 refactor: Relocate a type definition. 2023-09-08 13:12:50 -05:00
Daniel J. Geiger 1f9847ed98 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage 2023-09-08 10:31:19 -05:00
Daniel J. Geiger 4e4802b19e chore: Don't bundle #6050 or #5511. 2023-09-01 14:30:52 -05:00
Daniel J. Geiger 23eb08088e chore: Drop @excalidraw/extensions and move the MathJax subtype into
`src/excalidraw-app/subtypes` to leave `@excalidraw/excalidraw` untouched.

`@excalidraw/extensions` mostly contained boilerplate and obscured the
main new features here: `ExcalidrawElement` subtypes and MathJax support.
2023-09-01 13:40:27 -05:00
Daniel J. Geiger e8a6053251 Revert "Add a semicolon."
This reverts commit 456433e8f0.
2023-08-24 11:11:11 -05:00
Daniel J. Geiger 456433e8f0 Add a semicolon. 2023-08-24 10:35:12 -05:00
Daniel J. Geiger 38e3a4e8e1 fix: Further patch AsciiMath to work with Vite in production mode also. 2023-08-24 10:16:04 -05:00
Daniel J. Geiger 27a8cda8fd fix: Patch AsciiMath to work with Vite.
Incorporates PR mathjax/MathJax-src#854 by @masx200.
2023-08-23 11:27:12 -05:00
Daniel J. Geiger dd5053149a @excalidraw/extensions: Fixes for Vite. 2023-08-22 16:18:28 -05:00
Daniel J. Geiger 40ec02b280 chore: Update @excalidraw/extensions configs. 2023-08-22 09:54:13 -05:00
Daniel J. Geiger b81aa19ff9 fix: Migrate @excalidraw/extensions environment variable names to Vite. 2023-08-22 08:51:01 -05:00
Daniel J. Geiger e4ddd08bb1 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-08-21 16:09:37 -05:00
Daniel J. Geiger 795176b256 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-06-15 14:36:09 -05:00
Daniel J. Geiger be057bde39 MathJax: Use $ as LaTeX delimiters. Fall back to \( and \) if detected. Interpret \$ as a text literal "$" sign. 2023-06-15 13:56:33 -05:00
Daniel J. Geiger 94f4b727bb Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-05-17 14:36:53 -05:00
Daniel J. Geiger 63698572db Subtypes: add another test. 2023-04-28 13:03:03 -05:00
Daniel J. Geiger ab3467973f fix: No more debounced refresh() for subtypes. 2023-04-28 09:47:03 -05:00
Daniel J. Geiger 91fe07d9c5 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-26 16:43:42 -05:00
Daniel J. Geiger 28cc821047 Fix a merge lint issue 2023-04-24 15:29:55 -05:00
Daniel J. Geiger 7dc728a459 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-24 13:08:44 -05:00
Daniel J. Geiger 12c651af6d Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-20 18:52:45 -05:00
Daniel J. Geiger 9d0cafe10b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-14 18:34:08 -05:00
Daniel J. Geiger fb24221587 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-10 20:24:03 -05:00
Daniel J. Geiger ef347cc685 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-04-08 09:52:03 -05:00
Daniel J. Geiger 2d3b9e0c66 fix: Properly avoid concurrent invocations of loadMathJax(). 2023-03-18 09:52:44 -05:00
Daniel J. Geiger bdb0dd064b Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-17 11:19:18 -05:00
Daniel J. Geiger b17ed4dc29 fix: Don't cache wrapped text before MathJax finishes loading. 2023-03-13 13:01:52 -05:00
Daniel J. Geiger b988f67759 fix: Better legibility when editing some math elements. 2023-03-13 12:57:33 -05:00
Daniel J. Geiger 089aaa8792 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-13 11:33:26 -05:00
Daniel J. Geiger 28261c4b29 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-03-06 09:06:55 -06:00
Daniel J. Geiger 3fbed86d3e Fixes for math element dimensions before/upon loading MathJax. 2023-02-27 15:32:36 -06:00
Daniel J. Geiger 38b3d90fa6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 15:32:15 -06:00
Daniel J. Geiger 82b597ab8b fix: Catch MathML errors and render the "ERR" block instead. 2023-02-27 14:19:06 -06:00
Daniel J. Geiger 4c939cefad Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-27 14:18:41 -06:00
Daniel J. Geiger 8f0d9f5230 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-19 16:02:22 -06:00
Daniel J. Geiger fcde0ac3de Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-02-07 21:10:26 -06:00
Daniel J. Geiger b07dfba4b8 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-03 17:49:32 -06:00
Daniel J. Geiger 1089cdb278 Refactor: Modify fewer components. 2023-02-01 21:25:04 -06:00
Daniel J. Geiger 7246a6b17a Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-02-01 17:34:12 -06:00
Daniel J. Geiger 04a96caf78 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-31 15:26:03 -06:00
Daniel J. Geiger 14c6ea938a Refactor: Drop isActionName and convert getCustomActions to
`filterActions`.
2023-01-28 21:27:25 -06:00
Daniel J. Geiger 87aba3f619 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-28 18:44:03 -06:00
Daniel J. Geiger c8d4e8c421 Simplify custom Actions: universal Action predicates instead of
action-specific guards.
2023-01-27 13:23:40 -06:00
Daniel J. Geiger 512e506798 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-26 17:38:48 -06:00
Daniel J. Geiger b4e742bda0 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-25 16:54:14 -06:00
Daniel J. Geiger 5a3f4fd08f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-24 19:27:05 -06:00
Daniel J. Geiger 34515f2952 Fixes. 2023-01-24 19:09:07 -06:00
Daniel J. Geiger 08f430b3ac Fix tests. 2023-01-23 20:23:51 -06:00
Daniel J. Geiger 59e74f94e6 Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax 2023-01-17 15:12:39 -06:00
Daniel J. Geiger ddc393bd9d Make filtering of custom actions optional. 2023-01-08 19:30:01 -06:00
Daniel J. Geiger 9e5948ac28 Filter all context menu items (standard and custom) through
`isActionEnabled`.
2023-01-08 17:37:32 -06:00
Daniel J. Geiger f86d0f9102 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-08 17:06:23 -06:00
Daniel J. Geiger ace031e992 Update to latest Action changes. 2023-01-07 15:47:19 -06:00
Daniel J. Geiger 45faf7d58f Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-07 11:58:15 -06:00
Daniel J. Geiger 8c558a0f33 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-05 11:36:23 -06:00
Daniel J. Geiger 65059cb166 Fix tests introduced by the arrow labels feature. 2023-01-02 13:30:11 -06:00
Daniel J. Geiger 9158e2d989 fix: Remove leftovers from a merge. 2023-01-02 12:48:53 -06:00
Daniel J. Geiger 12da1862a0 Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2023-01-02 12:45:33 -06:00
Daniel J. Geiger 67fb3210ab fix: Correct existing subtypes test coverage; add test coverage for
subtype actions; and a subtype action fix.
2023-01-02 12:43:19 -06:00
Daniel J. Geiger 13d69d8cef Merge remote-tracking branch 'origin/master' into danieljgeiger-mathjax 2022-12-31 16:00:26 -06:00
Daniel J. Geiger 0f6ad916c0 fix: Cache SVGs separately for mixed-text and math-only modes. 2022-12-30 10:48:47 -06:00
Daniel J. Geiger 9ee2bf36cf Render LaTeX matrices correctly in math-only mode. 2022-12-30 10:26:46 -06:00
Daniel J. Geiger 86f5c2ebcf feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com 2022-12-27 15:11:52 -06:00
107 changed files with 4969 additions and 612 deletions
@@ -1,14 +1,14 @@
import { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "../utils";
import { debounce, getVersion, nFormatter } from "../src/utils";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "./data/localStorage";
import { DEFAULT_VERSION } from "../constants";
import { t } from "../i18n";
import { copyTextToSystemClipboard } from "../clipboard";
import { NonDeletedExcalidrawElement } from "../element/types";
import { UIAppState } from "../types";
import { DEFAULT_VERSION } from "../src/constants";
import { t } from "../src/i18n";
import { copyTextToSystemClipboard } from "../src/clipboard";
import { NonDeletedExcalidrawElement } from "../src/element/types";
import { UIAppState } from "../src/types";
type StorageSizes = { scene: number; total: number };
@@ -1,23 +1,23 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawImperativeAPI } from "../../src/types";
import { ErrorDialog } from "../../src/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../src/constants";
import { ImportedDataState } from "../../src/data/types";
import {
ExcalidrawElement,
InitializedExcalidrawImageElement,
} from "../../element/types";
} from "../../src/element/types";
import {
getSceneVersion,
restoreElements,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
} from "../../src/packages/excalidraw/index";
import { Collaborator, Gesture } from "../../src/types";
import {
preventUnload,
resolvablePromise,
withBatchedUpdates,
} from "../../utils";
} from "../../src/utils";
import {
CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
@@ -48,25 +48,25 @@ import {
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { t } from "../../i18n";
import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { t } from "../../src/i18n";
import { UserIdleState } from "../../src/types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { AbortError } from "../../errors";
import { AbortError } from "../../src/errors";
import {
isImageElement,
isInitializedImageElement,
} from "../../element/typeChecks";
import { newElementWith } from "../../element/mutateElement";
} from "../../src/element/typeChecks";
import { newElementWith } from "../../src/element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { decryptData } from "../../data/encryption";
import { decryptData } from "../../src/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai";
@@ -6,19 +6,19 @@ import {
import { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../element/types";
import { ExcalidrawElement } from "../../src/element/types";
import {
WS_EVENTS,
FILE_UPLOAD_TIMEOUT,
WS_SCENE_EVENT_TYPES,
} from "../app_constants";
import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics";
import { UserIdleState } from "../../src/types";
import { trackEvent } from "../../src/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "../../element/mutateElement";
import { newElementWith } from "../../src/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../constants";
import { encryptData } from "../../src/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
class Portal {
collab: TCollabClass;
@@ -1,4 +1,4 @@
@import "../../css/variables.module";
@import "../../src/css/variables.module";
.excalidraw {
.RoomDialog {
@@ -1,13 +1,13 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../clipboard";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
import { useI18n } from "../../i18n";
import { KEYS } from "../../keys";
import { copyTextToSystemClipboard } from "../../src/clipboard";
import { trackEvent } from "../../src/analytics";
import { getFrame } from "../../src/utils";
import { useI18n } from "../../src/i18n";
import { KEYS } from "../../src/keys";
import { Dialog } from "../../components/Dialog";
import { Dialog } from "../../src/components/Dialog";
import {
copyIcon,
playerPlayIcon,
@@ -16,11 +16,11 @@ import {
shareIOS,
shareWindows,
tablerCheckIcon,
} from "../../components/icons";
import { TextField } from "../../components/TextField";
import { FilledButton } from "../../components/FilledButton";
} from "../../src/components/icons";
import { TextField } from "../../src/components/TextField";
import { FilledButton } from "../../src/components/FilledButton";
import { ReactComponent as CollabImage } from "../../assets/lock.svg";
import { ReactComponent as CollabImage } from "../../src/assets/lock.svg";
import "./RoomDialog.scss";
const getShareIcon = () => {
@@ -1,7 +1,7 @@
import { PRECEDING_ELEMENT_KEY } from "../../constants";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { arrayToMapWithIndex } from "../../utils";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../src/types";
import { arrayToMapWithIndex } from "../../src/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
@@ -1,5 +1,5 @@
import React from "react";
import { Footer } from "../../packages/excalidraw/index";
import { Footer } from "../../src/packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
@@ -1,6 +1,6 @@
import React from "react";
import { PlusPromoIcon } from "../../components/icons";
import { MainMenu } from "../../packages/excalidraw/index";
import { PlusPromoIcon } from "../../src/components/icons";
import { MainMenu } from "../../src/packages/excalidraw/index";
import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{
@@ -1,9 +1,9 @@
import React from "react";
import { PlusPromoIcon } from "../../components/icons";
import { useI18n } from "../../i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { PlusPromoIcon } from "../../src/components/icons";
import { useI18n } from "../../src/i18n";
import { WelcomeScreen } from "../../src/packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "../../constants";
import { POINTER_EVENTS } from "../../src/constants";
export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any;
@@ -1,6 +1,6 @@
import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip";
import { useI18n } from "../../i18n";
import { shield } from "../../src/components/icons";
import { Tooltip } from "../../src/components/Tooltip";
import { useI18n } from "../../src/i18n";
export const EncryptedIcon = () => {
const { t } = useI18n();
@@ -1,20 +1,20 @@
import React from "react";
import { Card } from "../../components/Card";
import { ToolButton } from "../../components/ToolButton";
import { serializeAsJSON } from "../../data/json";
import { Card } from "../../src/components/Card";
import { ToolButton } from "../../src/components/ToolButton";
import { serializeAsJSON } from "../../src/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { FileId, NonDeletedExcalidrawElement } from "../../src/element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
import { nanoid } from "nanoid";
import { useI18n } from "../../i18n";
import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks";
import { useI18n } from "../../src/i18n";
import { encryptData, generateEncryptionKey } from "../../src/data/encryption";
import { isInitializedImageElement } from "../../src/element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
import { ExcalidrawLogo } from "../../components/ExcalidrawLogo";
import { MIME_TYPES } from "../../src/constants";
import { trackEvent } from "../../src/analytics";
import { getFrame } from "../../src/utils";
import { ExcalidrawLogo } from "../../src/components/ExcalidrawLogo";
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
@@ -1,7 +1,7 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../constants";
import { Theme } from "../../element/types";
import { THEME } from "../../src/constants";
import { Theme } from "../../src/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
@@ -1,8 +1,8 @@
import { useSetAtom } from "jotai";
import React from "react";
import { appLangCodeAtom } from "..";
import { useI18n } from "../../i18n";
import { languages } from "../../i18n";
import { useI18n } from "../../src/i18n";
import { languages } from "../../src/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();
@@ -1,19 +1,19 @@
import { compressData } from "../../data/encode";
import { newElementWith } from "../../element/mutateElement";
import { isInitializedImageElement } from "../../element/typeChecks";
import { compressData } from "../../src/data/encode";
import { newElementWith } from "../../src/element/mutateElement";
import { isInitializedImageElement } from "../../src/element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../element/types";
import { t } from "../../i18n";
} from "../../src/element/types";
import { t } from "../../src/i18n";
import {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
BinaryFiles,
} from "../../types";
} from "../../src/types";
export class FileManager {
/** files being fetched */
@@ -11,11 +11,11 @@
*/
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { ExcalidrawElement, FileId } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { debounce } from "../../utils";
import { clearAppStateForLocalStorage } from "../../src/appState";
import { clearElementsForLocalStorage } from "../../src/element";
import { ExcalidrawElement, FileId } from "../../src/element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
import { debounce } from "../../src/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
@@ -1,20 +1,20 @@
import { ExcalidrawElement, FileId } from "../../element/types";
import { getSceneVersion } from "../../element";
import { ExcalidrawElement, FileId } from "../../src/element/types";
import { getSceneVersion } from "../../src/element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
import { restoreElements } from "../../src/data/restore";
import {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../types";
} from "../../src/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode";
import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
import { decompressData } from "../../src/data/encode";
import { encryptData, decryptData } from "../../src/data/encryption";
import { MIME_TYPES } from "../../src/constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../utility-types";
import { ResolutionType } from "../../src/utility-types";
// private
// -----------------------------------------------------------------------------
@@ -1,23 +1,23 @@
import { compressData, decompressData } from "../../data/encode";
import { compressData, decompressData } from "../../src/data/encode";
import {
decryptData,
generateEncryptionKey,
IV_LENGTH_BYTES,
} from "../../data/encryption";
import { serializeAsJSON } from "../../data/json";
import { restore } from "../../data/restore";
import { ImportedDataState } from "../../data/types";
import { isInvisiblySmallElement } from "../../element/sizeHelpers";
import { isInitializedImageElement } from "../../element/typeChecks";
import { ExcalidrawElement, FileId } from "../../element/types";
import { t } from "../../i18n";
} from "../../src/data/encryption";
import { serializeAsJSON } from "../../src/data/json";
import { restore } from "../../src/data/restore";
import { ImportedDataState } from "../../src/data/types";
import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
import { isInitializedImageElement } from "../../src/element/typeChecks";
import { ExcalidrawElement, FileId } from "../../src/element/types";
import { t } from "../../src/i18n";
import {
AppState,
BinaryFileData,
BinaryFiles,
UserIdleState,
} from "../../types";
import { bytesToHexString } from "../../utils";
} from "../../src/types";
import { bytesToHexString } from "../../src/utils";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
@@ -1,12 +1,12 @@
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../src/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
} from "../../src/appState";
import { clearElementsForLocalStorage } from "../../src/element";
import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../data/types";
import { ImportedDataState } from "../../src/data/types";
export const saveUsernameToLocalStorage = (username: string) => {
try {
@@ -1,31 +1,32 @@
import polyfill from "../polyfill";
import polyfill from "../src/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { trackEvent } from "../src/analytics";
import { getDefaultAppState } from "../src/appState";
import { ErrorDialog } from "../src/components/ErrorDialog";
import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
import { useMathSubtype } from "../src/element/subtypes/mathjax";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "../constants";
import { loadFromBlob } from "../data/blob";
} from "../src/constants";
import { loadFromBlob } from "../src/data/blob";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
Theme,
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
} from "../src/element/types";
import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
import { t } from "../src/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
} from "../packages/excalidraw/index";
} from "../src/packages/excalidraw/index";
import {
AppState,
LibraryItems,
@@ -33,7 +34,7 @@ import {
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../types";
} from "../src/types";
import {
debounce,
getVersion,
@@ -43,7 +44,7 @@ import {
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../utils";
} from "../src/utils";
import {
FIREBASE_STORAGE_PREFIXES,
STORAGE_KEYS,
@@ -68,33 +69,40 @@ import {
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../src/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
import { newElementWith } from "../src/element/mutateElement";
import { isInitializedImageElement } from "../src/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "../src/data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../jotai";
import { useAtomWithInitialValue } from "../src/jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../utility-types";
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../components/Trans";
import { ResolutionType } from "../src/utility-types";
import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../src/components/Trans";
polyfill();
@@ -296,6 +304,8 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
useMathSubtype(excalidrawAPI);
const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -0,0 +1,29 @@
import { defaultLang } from "../../src/i18n";
import { UI } from "../../src/tests/helpers/ui";
import { screen, fireEvent, waitFor, render } from "../../src/tests/test-utils";
import ExcalidrawApp from "../../excalidraw-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());
});
});
@@ -1,11 +1,11 @@
import ExcalidrawApp from "../excalidraw-app";
import ExcalidrawApp from "../../excalidraw-app";
import {
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
} from "./test-utils";
} from "../../src/tests/test-utils";
import { UI } from "./helpers/ui";
import { UI } from "../../src/tests/helpers/ui";
describe("Test MobileMenu", () => {
const { h } = window;
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
import { vi } from "vitest";
import { render, updateSceneData, waitFor } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { API } from "./helpers/api";
import { createUndoAction } from "../actions/actionHistory";
import { render, updateSceneData, waitFor } from "../../src/tests/test-utils";
import ExcalidrawApp from "../../excalidraw-app";
import { API } from "../../src/tests/helpers/api";
import { createUndoAction } from "../../src/actions/actionHistory";
const { h } = window;
Object.defineProperty(window, "crypto", {
@@ -16,7 +16,7 @@ Object.defineProperty(window, "crypto", {
},
});
vi.mock("../excalidraw-app/data/index.ts", async (importActual) => {
vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => {
const module = (await importActual()) as any;
return {
__esmodule: true,
@@ -27,7 +27,7 @@ vi.mock("../excalidraw-app/data/index.ts", async (importActual) => {
};
});
vi.mock("../excalidraw-app/data/firebase.ts", () => {
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
const loadFromFirebase = async () => null;
const saveToFirebase = () => {};
const isSavedToFirebase = () => true;
@@ -1,13 +1,13 @@
import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../constants";
import { ExcalidrawElement } from "../element/types";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types";
import {
BroadcastedExcalidrawElement,
ReconciledElements,
reconcileElements,
} from "../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../random";
import { AppState } from "../types";
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../src/random";
import { AppState } from "../../src/types";
type Id = string;
type ElementLike = {
+6
View File
@@ -31,6 +31,7 @@
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
@@ -40,18 +41,22 @@
"image-blob-reduce": "3.0.1",
"jotai": "1.13.1",
"lodash.throttle": "4.1.1",
"mathjax-full": "https://github.com/MathJax/MathJax-src#develop",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "1.0.11",
"patch-package": "8.0.0",
"perfect-freehand": "1.2.0",
"pica": "7.1.1",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0",
"postinstall-postinstall": "2.1.0",
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "7.0.1",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
@@ -111,6 +116,7 @@
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"postinstall": "patch-package && yarn --cwd node_modules/mathjax-full compile-mjs && node scripts/beta-mathjax-import-paths.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "vite",
+126
View File
@@ -0,0 +1,126 @@
diff --git a/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js b/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js
index 3b228bb9..c8bcdea5 100644
--- a/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js
+++ b/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js
@@ -1,4 +1,4 @@
-MathJax = Object.assign(global.MathJax || {}, require("./MathJax.js").MathJax);
+window.MathJax = Object.assign(window.MathJax || {}, require("./MathJax.js").MathJax);
//
// Load component-based configuration, if any
@@ -13,10 +13,13 @@ MathJax.Ajax.Preloading(
"[MathJax]/jax/element/mml/jax.js"
);
-require("./jax/element/mml/jax.js");
-require("./jax/input/AsciiMath/config.js");
-require("./jax/input/AsciiMath/jax.js");
+module.exports.AsciiMath = void 0;
+(async () => {
+ await import("./jax/element/mml/jax.js");
+ await import("./jax/input/AsciiMath/config.js");
+ await import("./jax/input/AsciiMath/jax.js");
-require("./jax/element/MmlNode.js");
+ await import("./jax/element/MmlNode.js");
-module.exports.AsciiMath = MathJax.InputJax.AsciiMath;
+ module.exports.AsciiMath = MathJax.InputJax.AsciiMath;
+})();
diff --git a/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js b/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js
index 853b0a0e..1e009028 100644
--- a/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js
+++ b/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js
@@ -19,7 +19,7 @@ exports.MathJax = MathJax;
return obj;
};
var CONSTRUCTOR = function () {
- return function () {return arguments.callee.Init.call(this,arguments)};
+ return function fn() {return fn.Init.call(this,Object.assign(arguments,{call:fn}))};
};
BASE.Object = OBJECT({
@@ -40,7 +40,7 @@ exports.MathJax = MathJax;
Init: function (args) {
var obj = this;
if (args.length === 1 && args[0] === PROTO) {return obj}
- if (!(obj instanceof args.callee)) {obj = new args.callee(PROTO)}
+ if (!(obj instanceof args.call)) {obj = new args.call(PROTO)}
return obj.Init.apply(obj,args) || obj;
},
@@ -65,7 +65,7 @@ exports.MathJax = MathJax;
prototype: {
Init: function () {},
- SUPER: function (fn) {return fn.callee.SUPER},
+ SUPER: function (fn) {return fn.SUPER},
can: function (method) {return typeof(this[method]) === "function"},
has: function (property) {return typeof(this[property]) !== "undefined"},
isa: function (obj) {return (obj instanceof Object) && (this instanceof obj)}
@@ -177,7 +177,7 @@ exports.MathJax = MathJax;
// Create a callback from an associative array
//
var CALLBACK = function (data) {
- var cb = function () {return arguments.callee.execute.apply(arguments.callee,arguments)};
+ var cb = function fn() {return fn.execute.apply(fn,arguments)};
for (var id in CALLBACK.prototype) {
if (CALLBACK.prototype.hasOwnProperty(id)) {
if (typeof(data[id]) !== 'undefined') {cb[id] = data[id]}
diff --git a/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js b/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js
index 96fb9186..473aca11 100644
--- a/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js
+++ b/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js
@@ -813,9 +813,9 @@ MathJax.ElementJax.mml.Augment({
if (!(this.isEmbellished()) || typeof(this.core) === "undefined") {return this}
return this.data[this.core].CoreMO();
},
- toString: function () {
+ toString: function fn() {
if (this.inferred) {return '[' + this.data.join(',') + ']'}
- return this.SUPER(arguments).toString.call(this);
+ return this.SUPER(fn).toString.call(this);
},
setTeXclass: function (prev) {
var i, m = this.data.length;
@@ -1196,12 +1196,12 @@ MathJax.ElementJax.mml.Augment({
}
},
linebreakContainer: true,
- Append: function () {
+ Append: function fn() {
for (var i = 0, m = arguments.length; i < m; i++) {
if (!((arguments[i] instanceof MML.mtr) ||
(arguments[i] instanceof MML.mlabeledtr))) {arguments[i] = MML.mtr(arguments[i])}
}
- this.SUPER(arguments).Append.apply(this,arguments);
+ this.SUPER(fn).Append.apply(this,arguments);
},
setTeXclass: MML.mbase.setSeparateTeXclasses
});
@@ -1221,11 +1221,11 @@ MathJax.ElementJax.mml.Augment({
mtable: {rowalign: true, columnalign: true, groupalign: true}
},
linebreakContainer: true,
- Append: function () {
+ Append: function fn() {
for (var i = 0, m = arguments.length; i < m; i++) {
if (!(arguments[i] instanceof MML.mtd)) {arguments[i] = MML.mtd(arguments[i])}
}
- this.SUPER(arguments).Append.apply(this,arguments);
+ this.SUPER(fn).Append.apply(this,arguments);
},
setTeXclass: MML.mbase.setSeparateTeXclasses
});
@@ -1420,9 +1420,9 @@ MathJax.ElementJax.mml.Augment({
MML.xml = MML.mbase.Subclass({
type: "xml",
- Init: function () {
+ Init: function fn() {
this.div = document.createElement("div");
- return this.SUPER(arguments).Init.apply(this,arguments);
+ return this.SUPER(fn).Init.apply(this,arguments);
},
Append: function () {
for (var i = 0, m = arguments.length; i < m; i++) {
+10
View File
@@ -0,0 +1,10 @@
// When building MathJax 4.0-beta from source within the Excalidraw tree, some
// import paths don't properly translate from `ts/` to `mjs/`. This makes the
// Excalidraw build process parse MathJax TypeScript files. The resulting error
// messages do not occur if MathJax was built from source outside the
// Excalidraw tree. The following regexp eliminates those error messages.
require("replace-in-file").sync({
files: "node_modules/mathjax-full/mjs/**/*",
from: /mathjax-full\/ts/g,
to: "mathjax-full/mjs",
});
+6 -6
View File
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition,
computeContainerDimensionForBoundText,
getBoundTextElement,
measureText,
measureTextElement,
redrawTextBoundingBox,
} from "../element/textElement";
import {
@@ -31,7 +31,6 @@ import {
} from "../element/types";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
@@ -48,10 +47,11 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
const { width, height, baseline } = measureTextElement(
boundTextElement,
{
text: boundTextElement.originalText,
},
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
+62 -11
View File
@@ -2,10 +2,10 @@ import React from "react";
import {
Action,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
ActionPredicateFn,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
@@ -40,7 +40,8 @@ const trackAction = (
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
actions = {} as Record<Action["name"], Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -68,6 +69,37 @@ export class ActionManager {
this.app = app;
}
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
filterActions(
filter: ActionPredicateFn,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
},
): Action[] {
// For testing
if (this === undefined) {
return [];
}
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
const actions: Action[] = [];
for (const key in this.actions) {
const action = this.actions[key];
if (filter(action, elements, appState, data)) {
actions.push(action);
}
}
return actions;
}
registerAction(action: Action) {
this.actions[action.name] = action;
}
@@ -84,7 +116,7 @@ export class ActionManager {
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
: this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest &&
action.keyTest(
event,
@@ -135,7 +167,7 @@ export class ActionManager {
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
renderAction = (name: Action["name"], data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@@ -143,7 +175,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@@ -165,6 +197,7 @@ export class ActionManager {
return (
<PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
@@ -178,13 +211,31 @@ export class ActionManager {
return null;
};
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
isActionEnabled = (
action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
if (
!opts?.noPredicates &&
action.predicate &&
!action.predicate(elements, appState, this.app.props, this.app, data)
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, data)) {
enabled = false;
}
});
return enabled;
};
}
+17 -2
View File
@@ -83,8 +83,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
export type CustomShortcutName = string;
let customShortcutMap: Record<CustomShortcutName, string[]> = {};
export const registerCustomShortcuts = (
shortcuts: Record<CustomShortcutName, string[]>,
) => {
customShortcutMap = { ...customShortcutMap, ...shortcuts };
};
export const getShortcutFromShortcutName = (
name: ShortcutName | CustomShortcutName,
) => {
const shortcuts =
name in customShortcutMap
? customShortcutMap[name as CustomShortcutName]
: shortcutMap[name as ShortcutName];
// if multiple shortcuts available, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};
+11 -1
View File
@@ -32,6 +32,15 @@ type ActionFn = (
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
@@ -135,7 +144,7 @@ export type PanelComponentProps = {
};
export interface Action {
name: ActionName;
name: string;
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
@@ -157,6 +166,7 @@ export interface Action {
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:
+2
View File
@@ -146,6 +146,8 @@ const APP_STATE_STORAGE_CONF = (<
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
activeSubtypes: { browser: true, export: false, server: false },
customData: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
+21 -1
View File
@@ -11,6 +11,8 @@ import {
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
import { AppState } from "./types";
import { selectSubtype } from "./element/subtypes";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
@@ -23,6 +25,8 @@ export interface Spreadsheet {
title: string | null;
labels: string[] | null;
values: number[];
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
}
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
@@ -193,13 +197,17 @@ const chartXLabels = (
groupId: string,
backgroundColor: string,
): ChartElements => {
const custom = selectSubtype(spreadsheet, "text");
return (
spreadsheet.labels?.map((label, index) => {
return newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
text:
label.length > 8 && custom.subtype === undefined
? `${label.slice(0, 5)}...`
: label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
@@ -207,6 +215,7 @@ const chartXLabels = (
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
...custom,
});
}) || []
);
@@ -227,6 +236,7 @@ const chartYLabels = (
y: y - BAR_GAP,
text: "0",
textAlign: "right",
...selectSubtype(spreadsheet, "text"),
});
const maxYLabel = newTextElement({
@@ -237,6 +247,7 @@ const chartYLabels = (
y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right",
...selectSubtype(spreadsheet, "text"),
});
return [minYLabel, maxYLabel];
@@ -264,6 +275,7 @@ const chartLines = (
[0, 0],
[chartWidth, 0],
],
...selectSubtype(spreadsheet, "line"),
});
const yLine = newLinearElement({
@@ -280,6 +292,7 @@ const chartLines = (
[0, 0],
[0, -chartHeight],
],
...selectSubtype(spreadsheet, "line"),
});
const maxLine = newLinearElement({
@@ -298,6 +311,7 @@ const chartLines = (
[0, 0],
[chartWidth, 0],
],
...selectSubtype(spreadsheet, "line"),
});
return [xLine, yLine, maxLine];
@@ -324,6 +338,7 @@ const chartBaseElements = (
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null,
textAlign: "center",
...selectSubtype(spreadsheet, "text"),
})
: null;
@@ -340,6 +355,7 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
...selectSubtype(spreadsheet, "rectangle"),
})
: null;
@@ -372,6 +388,7 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
...selectSubtype(spreadsheet, "rectangle"),
});
});
@@ -424,6 +441,7 @@ const chartTypeLine = (
width: maxX - minX,
strokeWidth: 2,
points: points as any,
...selectSubtype(spreadsheet, "line"),
});
const dots = spreadsheet.values.map((value, index) => {
@@ -440,6 +458,7 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2,
width: BAR_GAP,
height: BAR_GAP,
...selectSubtype(spreadsheet, "ellipse"),
});
});
@@ -462,6 +481,7 @@ const chartTypeLine = (
[0, 0],
[0, cy],
],
...selectSubtype(spreadsheet, "line"),
});
});
+6 -1
View File
@@ -2,7 +2,7 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { BinaryFiles } from "./types";
import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
@@ -167,6 +167,7 @@ export const getSystemClipboard = async (
export const parseClipboard = async (
event: ClipboardEvent | null,
isPlainPaste = false,
appState?: AppState,
): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event);
@@ -186,6 +187,10 @@ export const parseClipboard = async (
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
if ("spreadsheet" in spreadsheetResult) {
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
spreadsheetResult.spreadsheet.customData = appState?.customData;
}
return spreadsheetResult;
}
+3
View File
@@ -23,6 +23,7 @@ import {
} from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { SubtypeShapeActions, SubtypeToggles } from "./Subtypes";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks";
@@ -100,6 +101,7 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
<SubtypeShapeActions elements={targetElements} />
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) ||
@@ -400,6 +402,7 @@ export const ShapesSwitcher = ({
</DropdownMenu.Content>
</DropdownMenu>
)}
<SubtypeToggles />
</>
);
};
+72 -12
View File
@@ -270,6 +270,16 @@ import {
import LayerUI from "./LayerUI";
import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import {
SubtypeLoadedCb,
SubtypeRecord,
SubtypePrepFn,
checkRefreshOnSubtypeLoad,
isSubtypeAction,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../element/subtypes";
import {
dataURLToFile,
generateIdFromFile,
@@ -509,6 +519,12 @@ class App extends React.Component<AppProps, AppState> {
this.id = nanoid();
this.library = new Library(this);
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.scene = new Scene();
this.canvas = document.createElement("canvas");
@@ -535,6 +551,8 @@ class App extends React.Component<AppProps, AppState> {
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
actionManager: this.actionManager,
addSubtype: this.addSubtype,
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
@@ -562,16 +580,27 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History();
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
this.actionManager.registerActionPredicate(subtypeActionPredicate);
}
private addSubtype(record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) {
const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => {
const elements = this.getSceneElementsIncludingDeleted();
// If there are any elements of the just-registered subtype,
// refresh the scene to re-render each such element.
if (checkRefreshOnSubtypeLoad(hasSubtype, elements)) {
this.refresh();
}
};
const prep = prepareSubtype(record, subtypePrepFn, subtypeLoadedCb);
if (prep.actions) {
this.actionManager.registerAll(prep.actions);
}
return prep;
}
private onWindowMessage(event: MessageEvent) {
@@ -1330,7 +1359,8 @@ class App extends React.Component<AppProps, AppState> {
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
jotaiStore.set(activeEyeDropperAtom, {
swapPreviewOnAlt: true,
previewType: type === "stroke" ? "strokeColor" : "backgroundColor",
colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground",
onSelect: (color, event) => {
const shouldUpdateStrokeColor =
(type === "background" && event.altKey) ||
@@ -1341,12 +1371,14 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type !== "selection"
) {
if (shouldUpdateStrokeColor) {
this.setState({
currentItemStrokeColor: color,
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
commitToHistory: true,
});
} else {
this.setState({
currentItemBackgroundColor: color,
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
commitToHistory: true,
});
}
} else {
@@ -2156,7 +2188,7 @@ class App extends React.Component<AppProps, AppState> {
// (something something security)
let file = event?.clipboardData?.files[0];
const data = await parseClipboard(event, isPlainPaste);
const data = await parseClipboard(event, isPlainPaste, this.state);
if (!file && data.text && !isPlainPaste) {
const string = data.text.trim();
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
@@ -2386,6 +2418,7 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: this.state.currentItemFontFamily,
textAlign: this.state.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
locked: false,
};
@@ -3522,6 +3555,7 @@ class App extends React.Component<AppProps, AppState> {
verticalAlign: parentCenterPosition
? VERTICAL_ALIGN.MIDDLE
: DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [],
lineHeight,
@@ -5375,6 +5409,7 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness,
roundness: null,
opacity: this.state.currentItemOpacity,
...selectSubtype(this.state, "image"),
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
@@ -5475,6 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
: null,
startArrowhead,
endArrowhead,
...selectSubtype(this.state, elementType),
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
@@ -5551,6 +5587,7 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness: this.getCurrentItemRoundness(elementType),
...selectSubtype(this.state, elementType),
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
} as const;
@@ -7871,6 +7908,29 @@ class App extends React.Component<AppProps, AppState> {
private getContextMenuItems = (
type: "canvas" | "element",
): ContextMenuItems => {
const subtype: ContextMenuItems = [];
this.actionManager
.filterActions(isSubtypeAction)
.forEach(
(action) =>
this.actionManager.isActionEnabled(action, { data: {} }) &&
subtype.push(action),
);
if (subtype.length > 0) {
subtype.push(CONTEXT_MENU_SEPARATOR);
}
const standard: ContextMenuItems = this._getContextMenuItems(type).filter(
(item) =>
!item ||
item === CONTEXT_MENU_SEPARATOR ||
this.actionManager.isActionEnabled(item, { noPredicates: true }),
);
return [...subtype, ...standard];
};
private _getContextMenuItems = (
type: "canvas" | "element",
): ContextMenuItems => {
const options: ContextMenuItems = [];
+7 -4
View File
@@ -1,7 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import {
ColorPickerType,
activeColorPickerSectionAtom,
} from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai";
import { KEYS } from "../../keys";
@@ -15,14 +18,14 @@ interface ColorInputProps {
color: string;
onChange: (color: string) => void;
label: string;
eyeDropperType: "strokeColor" | "backgroundColor";
colorPickerType: ColorPickerType;
}
export const ColorInput = ({
color,
onChange,
label,
eyeDropperType,
colorPickerType,
}: ColorInputProps) => {
const device = useDevice();
const [innerValue, setInnerValue] = useState(color);
@@ -116,7 +119,7 @@ export const ColorInput = ({
: {
keepOpenOnAlt: false,
onSelect: (color) => onChange(color),
previewType: eyeDropperType,
colorPickerType,
},
)
}
+5 -12
View File
@@ -82,14 +82,7 @@ const ColorPickerPopupContent = ({
const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice();
const eyeDropperType =
type === "canvasBackground"
? undefined
: type === "elementBackground"
? "backgroundColor"
: "strokeColor";
const colorInputJSX = eyeDropperType && (
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput
@@ -98,7 +91,7 @@ const ColorPickerPopupContent = ({
onChange={(color) => {
onChange(color);
}}
eyeDropperType={eyeDropperType}
colorPickerType={type}
/>
</div>
);
@@ -160,7 +153,7 @@ const ColorPickerPopupContent = ({
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette && eyeDropperType ? (
{palette ? (
<Picker
palette={palette}
color={color}
@@ -173,7 +166,7 @@ const ColorPickerPopupContent = ({
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
previewType: eyeDropperType,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
@@ -184,7 +177,7 @@ const ColorPickerPopupContent = ({
: {
keepOpenOnAlt: false,
onSelect: onChange,
previewType: eyeDropperType,
colorPickerType: type,
};
});
}}
+50 -36
View File
@@ -1,35 +1,47 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react";
import React, { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { rgbToHex } from "../colors";
import { EVENT } from "../constants";
import { useUIAppState } from "../context/ui-appState";
import { mutateElement } from "../element/mutateElement";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import Scene from "../scene/Scene";
import { ShapeCache } from "../scene/ShapeCache";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import { useStable } from "../hooks/useStable";
import "./EyeDropper.scss";
import { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import { ExcalidrawElement } from "../element/types";
type EyeDropperProperties = {
export type EyeDropperProperties = {
keepOpenOnAlt: boolean;
swapPreviewOnAlt?: boolean;
/** called when user picks color (on pointerup) */
onSelect: (color: string, event: PointerEvent) => void;
previewType: "strokeColor" | "backgroundColor";
/**
* property of selected elements to update live when alt-dragging.
* Supply `null` if not applicable (e.g. updating the canvas bg instead of
* elements)
**/
colorPickerType: ColorPickerType;
};
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
export const EyeDropper: React.FC<{
onCancel: () => void;
onSelect: Required<EyeDropperProperties>["onSelect"];
swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"];
previewType: EyeDropperProperties["previewType"];
}> = ({ onCancel, onSelect, swapPreviewOnAlt, previewType }) => {
onSelect: EyeDropperProperties["onSelect"];
/** called when color changes, on pointerdown for preview */
onChange: (
type: ColorPickerType,
color: string,
selectedElements: ExcalidrawElement[],
event: { altKey: boolean },
) => void;
colorPickerType: EyeDropperProperties["colorPickerType"];
}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
const eyeDropperContainer = useCreatePortalContainer({
className: "excalidraw-eye-dropper-backdrop",
parentSelector: ".excalidraw-eye-dropper-container",
@@ -40,9 +52,13 @@ export const EyeDropper: React.FC<{
const selectedElements = getSelectedElements(elements, appState);
const metaStuffRef = useRef({ selectedElements, app });
metaStuffRef.current.selectedElements = selectedElements;
metaStuffRef.current.app = app;
const stableProps = useStable({
app,
onCancel,
onChange,
onSelect,
selectedElements,
});
const { container: excalidrawContainer } = useExcalidrawContainer();
@@ -90,28 +106,28 @@ export const EyeDropper: React.FC<{
const currentColor = getCurrentColor({ clientX, clientY });
if (isHoldingPointerDown) {
for (const element of metaStuffRef.current.selectedElements) {
mutateElement(
element,
{
[altKey && swapPreviewOnAlt
? previewType === "strokeColor"
? "backgroundColor"
: "strokeColor"
: previewType]: currentColor,
},
false,
);
ShapeCache.delete(element);
}
Scene.getScene(
metaStuffRef.current.selectedElements[0],
)?.informMutation();
stableProps.onChange(
colorPickerType,
currentColor,
stableProps.selectedElements,
{ altKey },
);
}
colorPreviewDiv.style.background = currentColor;
};
const onCancel = () => {
stableProps.onCancel();
};
const onSelect: Required<EyeDropperProperties>["onSelect"] = (
color,
event,
) => {
stableProps.onSelect(color, event);
};
const pointerDownListener = (event: PointerEvent) => {
isHoldingPointerDown = true;
// NOTE we can't event.preventDefault() as that would stop
@@ -148,8 +164,8 @@ export const EyeDropper: React.FC<{
// init color preview else it would show only after the first mouse move
mouseMoveListener({
clientX: metaStuffRef.current.app.lastViewportPosition.x,
clientY: metaStuffRef.current.app.lastViewportPosition.y,
clientX: stableProps.app.lastViewportPosition.x,
clientY: stableProps.app.lastViewportPosition.y,
altKey: false,
});
@@ -179,12 +195,10 @@ export const EyeDropper: React.FC<{
window.removeEventListener(EVENT.BLUR, onCancel);
};
}, [
stableProps,
app.canvas,
eyeDropperContainer,
onCancel,
onSelect,
swapPreviewOnAlt,
previewType,
colorPickerType,
excalidrawContainer,
appState.offsetLeft,
appState.offsetTop,
+38 -2
View File
@@ -52,6 +52,9 @@ import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
interface LayerUIProps {
actionManager: ActionManager;
@@ -368,11 +371,44 @@ const LayerUI = ({
)}
{eyeDropperState && !device.isMobile && (
<EyeDropper
swapPreviewOnAlt={eyeDropperState.swapPreviewOnAlt}
previewType={eyeDropperState.previewType}
colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => {
setEyeDropperState(null);
}}
onChange={(colorPickerType, color, selectedElements, { altKey }) => {
if (
colorPickerType !== "elementBackground" &&
colorPickerType !== "elementStroke"
) {
return;
}
if (selectedElements.length) {
for (const element of selectedElements) {
mutateElement(
element,
{
[altKey && eyeDropperState.swapPreviewOnAlt
? colorPickerType === "elementBackground"
? "strokeColor"
: "backgroundColor"
: colorPickerType === "elementBackground"
? "backgroundColor"
: "strokeColor"]: color,
},
false,
);
ShapeCache.delete(element);
}
Scene.getScene(selectedElements[0])?.informMutation();
} else if (colorPickerType === "elementBackground") {
setAppState({
currentItemBackgroundColor: color,
});
} else {
setAppState({ currentItemStrokeColor: color });
}
}}
onSelect={(color, event) => {
setEyeDropperState((state) => {
return state?.keepOpenOnAlt && event.altKey ? state : null;
+49 -30
View File
@@ -10,6 +10,12 @@ import { useApp } from "./App";
import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss";
import { ensureSubtypesLoaded } from "../element/subtypes";
import { isTextElement } from "../element";
import {
getContainerElement,
redrawTextBoundingBox,
} from "../element/textElement";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -25,41 +31,54 @@ const ChartPreviewBtn = (props: {
);
useLayoutEffect(() => {
if (!props.spreadsheet) {
return;
}
const elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
setChartElements(elements);
let svg: SVGSVGElement;
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
(async () => {
let elements: ChartElements;
await ensureSubtypesLoaded(
props.spreadsheet?.activeSubtypes ?? [],
() => {
if (!props.spreadsheet) {
return;
}
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
elements.forEach(
(el) =>
isTextElement(el) &&
redrawTextBoundingBox(el, getContainerElement(el)),
);
setChartElements(elements);
},
).then(async () => {
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
});
})();
return () => {
previewNode.replaceChildren();
};
})();
return () => {
previewNode.replaceChildren();
};
}, [props.spreadsheet, props.chartType, props.selected]);
return (
+173
View File
@@ -0,0 +1,173 @@
import { getShortcutKey, updateActiveTool } from "../utils";
import { t } from "../i18n";
import { Action } from "../actions/types";
import clsx from "clsx";
import {
Subtype,
getSubtypeNames,
hasAlwaysEnabledActions,
isSubtypeAction,
isValidSubtype,
subtypeCollides,
} from "../element/subtypes";
import { ExcalidrawElement, Theme } from "../element/types";
import {
useExcalidrawActionManager,
useExcalidrawContainer,
useExcalidrawSetAppState,
} from "./App";
import { ContextMenuItems } from "./ContextMenu";
export const SubtypeButton = (
subtype: Subtype,
parentType: ExcalidrawElement["type"],
icon: ({ theme }: { theme: Theme }) => JSX.Element,
key?: string,
) => {
const title = key !== undefined ? ` - ${getShortcutKey(key)}` : "";
const keyTest: Action["keyTest"] =
key !== undefined ? (event) => event.code === `Key${key}` : undefined;
const subtypeAction: Action = {
name: subtype,
trackEvent: false,
predicate: (...rest) => rest[4]?.subtype === subtype,
perform: (elements, appState) => {
const inactive = !appState.activeSubtypes?.includes(subtype) ?? true;
const activeSubtypes: Subtype[] = [];
if (appState.activeSubtypes) {
activeSubtypes.push(...appState.activeSubtypes);
}
let activated = false;
if (inactive) {
// Ensure `element.subtype` is well-defined
if (!subtypeCollides(subtype, activeSubtypes)) {
activeSubtypes.push(subtype);
activated = true;
}
} else {
// Can only be active if appState.activeSubtypes is defined
// and contains subtype.
activeSubtypes.splice(activeSubtypes.indexOf(subtype), 1);
}
const type =
appState.activeTool.type !== "custom" &&
isValidSubtype(subtype, appState.activeTool.type)
? appState.activeTool.type
: parentType;
const activeTool = !inactive
? appState.activeTool
: updateActiveTool(appState, { type });
const selectedElementIds = activated ? {} : appState.selectedElementIds;
const selectedGroupIds = activated ? {} : appState.selectedGroupIds;
return {
appState: {
...appState,
activeSubtypes,
selectedElementIds,
selectedGroupIds,
activeTool,
},
commitToHistory: true,
};
},
keyTest,
PanelComponent: ({ elements, appState, updateData, data }) => (
<button
className={clsx("ToolIcon_type_button", "ToolIcon_type_button--show", {
ToolIcon: true,
"ToolIcon--selected":
appState.activeSubtypes !== undefined &&
appState.activeSubtypes.includes(subtype),
"ToolIcon--plain": true,
})}
title={`${t(`toolBar.${subtype}`)}${title}`}
aria-label={t(`toolBar.${subtype}`)}
onClick={() => {
updateData(null);
}}
onContextMenu={
data && "onContextMenu" in data
? (event: React.MouseEvent) => {
if (
appState.activeSubtypes === undefined ||
(appState.activeSubtypes !== undefined &&
!appState.activeSubtypes.includes(subtype))
) {
updateData(null);
}
data.onContextMenu(event, subtype);
}
: undefined
}
>
{
<div className="ToolIcon__icon" aria-hidden="true">
{icon.call(this, { theme: appState.theme })}
</div>
}
</button>
),
};
if (key === "") {
delete subtypeAction.keyTest;
}
return subtypeAction;
};
export const SubtypeToggles = () => {
const am = useExcalidrawActionManager();
const { container } = useExcalidrawContainer();
const setAppState = useExcalidrawSetAppState();
const onContextMenu = (
event: React.MouseEvent<HTMLButtonElement>,
subtype: string,
) => {
event.preventDefault();
const { top: offsetTop, left: offsetLeft } =
container!.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
const items: ContextMenuItems = [];
am.filterActions(isSubtypeAction).forEach(
(action) =>
am.isActionEnabled(action, { data: { subtype } }) && items.push(action),
);
setAppState({}, () => {
setAppState({
contextMenu: { top, left, items },
});
});
};
return (
<>
{getSubtypeNames().map((subtype) =>
am.renderAction(
subtype,
hasAlwaysEnabledActions(subtype) ? { onContextMenu } : {},
),
)}
</>
);
};
SubtypeToggles.displayName = "SubtypeToggles";
export const SubtypeShapeActions = (props: {
elements: readonly ExcalidrawElement[];
}) => {
const am = useExcalidrawActionManager();
return (
<>
{am
.filterActions(isSubtypeAction, { elements: props.elements })
.map((action) => am.renderAction(action.name))}
</>
);
};
SubtypeShapeActions.displayName = "SubtypeShapeActions";
+1 -1
View File
@@ -13,7 +13,7 @@ import clsx from "clsx";
import { Theme } from "../element/types";
import { THEME } from "../constants";
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
+15 -8
View File
@@ -34,13 +34,14 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { isValidSubtype } from "../element/subtypes";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
measureTextElement,
} from "../element/textElement";
import { normalizeLink } from "./url";
@@ -92,7 +93,8 @@ const repairBinding = (binding: PointBinding | null) => {
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -158,6 +160,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false,
};
if ("subtype" in element && isValidSubtype(element.subtype, base.type)) {
base.subtype = element.subtype;
}
if ("customData" in element) {
base.customData = element.customData;
}
@@ -203,11 +208,7 @@ const restoreElement = (
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
const baseline = measureTextElement(element, { text }).baseline;
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@@ -528,6 +529,12 @@ export const restoreAppState = (
: defaultValue;
}
if ("activeSubtypes" in appState) {
nextAppState.activeSubtypes = appState.activeSubtypes;
}
if ("customData" in appState) {
nextAppState.customData = appState.customData;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
+26 -3
View File
@@ -6,12 +6,23 @@ import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { maybeGetSubtypeProps } from "./newElement";
import { getSubtypeMethods } from "./subtypes";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce"
>;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
const map = getSubtypeMethods(subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you
@@ -22,6 +33,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -70,6 +83,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
}
}
if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue;
}
}
@@ -77,6 +91,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value;
didChange = true;
key in oldUpdates && (increment = true);
}
}
if (!didChange) {
@@ -92,9 +107,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element);
}
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (increment) {
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) {
Scene.getScene(element)?.informMutation();
@@ -108,6 +125,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
updates: ElementUpdate<TElement>,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
@@ -119,6 +138,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue;
}
didChange = true;
key in oldUpdates && (increment = true);
}
}
@@ -126,6 +146,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element;
}
if (!increment) {
return { ...element, ...updates };
}
return {
...element,
...updates,
-1
View File
@@ -203,7 +203,6 @@ describe("duplicating multiple elements", () => {
);
clonedArrows.forEach((arrow) => {
// console.log(arrow);
expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual(
+54 -24
View File
@@ -15,12 +15,7 @@ import {
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
} from "../element/types";
import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
@@ -30,9 +25,9 @@ import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getContainerElement,
measureText,
measureTextElement,
normalizeText,
wrapText,
wrapTextElement,
getBoundTextMaxWidth,
getDefaultLineHeight,
} from "./textElement";
@@ -45,6 +40,30 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import { MarkOptional, Merge, Mutable } from "../utility-types";
import { getSubtypeMethods, isValidSubtype } from "./subtypes";
export const maybeGetSubtypeProps = (
obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
},
type: ExcalidrawElement["type"],
) => {
const data: typeof obj = {};
if ("subtype" in obj) {
data.subtype = obj.subtype;
}
if ("customData" in obj) {
data.customData = obj.customData;
}
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
delete data.subtype;
}
if (!("subtype" in data) && "customData" in data) {
delete data.customData;
}
return data as typeof obj;
};
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -58,6 +77,8 @@ export type ElementConstructorOpts = MarkOptional<
| "version"
| "versionNonce"
| "link"
| "subtype"
| "customData"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
@@ -93,8 +114,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }, type),
id: rest.id || randomId(),
type,
x,
@@ -128,8 +151,11 @@ export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
): NonDeleted<ExcalidrawGenericElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = (
opts: {
@@ -196,10 +222,12 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(
text,
getFontString({ fontFamily, fontSize }),
lineHeight,
const metrics = measureTextElement(
{ ...opts, fontSize, fontFamily, lineHeight },
{
text,
customData: opts.customData,
},
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -244,7 +272,9 @@ const getAdjustedDimensions = (
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight);
} = measureTextElement(element, {
text: nextText,
});
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -253,11 +283,7 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const prevMetrics = measureTextElement(element);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@@ -313,11 +339,9 @@ export const refreshTextDimensions = (
}
const container = getContainerElement(textElement);
if (container) {
text = wrapText(
text = wrapTextElement(textElement, getBoundTextMaxWidth(container), {
text,
getFontString(textElement),
getBoundTextMaxWidth(container),
);
});
}
const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions };
@@ -349,6 +373,8 @@ export const newFreeDrawElement = (
simulatePressure: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [],
@@ -366,6 +392,8 @@ export const newLinearElement = (
points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [],
@@ -385,6 +413,8 @@ export const newImageElement = (
scale?: ExcalidrawImageElement["scale"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return {
..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements,
+2 -6
View File
@@ -51,7 +51,7 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
measureTextElement,
getBoundTextMaxHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@@ -223,11 +223,7 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
const metrics = measureTextElement(element, { fontSize: nextFontSize });
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
+490
View File
@@ -0,0 +1,490 @@
import { useEffect } from "react";
import { ExcalidrawElement, ExcalidrawTextElement, NonDeleted } from "../types";
import { getNonDeletedElements } from "../";
import { getSelectedElements } from "../../scene";
import { AppState, ExcalidrawImperativeAPI } from "../../types";
import { registerAuxLangData } from "../../i18n";
import { Action, ActionName, ActionPredicateFn } from "../../actions/types";
import {
CustomShortcutName,
registerCustomShortcuts,
} from "../../actions/shortcuts";
import { register } from "../../actions/register";
import { hasBoundTextElement, isTextElement } from "../typeChecks";
import {
getBoundTextElement,
getContainerElement,
redrawTextBoundingBox,
} from "../textElement";
import { ShapeCache } from "../../scene/ShapeCache";
import Scene from "../../scene/Scene";
// Use "let" instead of "const" so we can dynamically add subtypes
let subtypeNames: readonly Subtype[] = [];
let parentTypeMap: readonly {
subtype: Subtype;
parentType: ExcalidrawElement["type"];
}[] = [];
let subtypeActionMap: readonly {
subtype: Subtype;
actions: readonly SubtypeActionName[];
}[] = [];
let disabledActionMap: readonly {
subtype: Subtype;
actions: readonly DisabledActionName[];
}[] = [];
let alwaysEnabledMap: readonly {
subtype: Subtype;
actions: readonly SubtypeActionName[];
}[] = [];
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly ExcalidrawElement["type"][];
actionNames?: readonly SubtypeActionName[];
disabledNames?: readonly DisabledActionName[];
shortcutMap?: Record<CustomShortcutName, string[]>;
alwaysEnabledNames?: readonly SubtypeActionName[];
}>;
// Subtype Names
export type Subtype = Required<ExcalidrawElement>["subtype"];
export const getSubtypeNames = (): readonly Subtype[] => {
return subtypeNames;
};
export const isValidSubtype = (s: any, t: any): s is Subtype =>
parentTypeMap.find(
(val) => (val.subtype as any) === s && (val.parentType as any) === t,
) !== undefined;
const isSubtypeName = (s: any): s is Subtype => subtypeNames.includes(s);
// Subtype Actions
// Used for context menus in the shape chooser
export const hasAlwaysEnabledActions = (s: any): boolean => {
if (!isSubtypeName(s)) {
return false;
}
return alwaysEnabledMap.some((value) => value.subtype === s);
};
type SubtypeActionName = string;
const isSubtypeActionName = (s: any): s is SubtypeActionName =>
subtypeActionMap.some((val) => val.actions.includes(s));
const addSubtypeAction = (action: Action) => {
if (isSubtypeActionName(action.name) || isSubtypeName(action.name)) {
register(action);
}
};
// Standard actions disabled by subtypes
type DisabledActionName = ActionName;
const isDisabledActionName = (s: any): s is DisabledActionName =>
disabledActionMap.some((val) => val.actions.includes(s));
// Is the `actionName` one of the subtype actions for `subtype`
// (if `isAdded` is true) or one of the standard actions disabled
// by `subtype` (if `isAdded` is false)?
const isForSubtype = (
subtype: ExcalidrawElement["subtype"],
actionName: ActionName | SubtypeActionName,
isAdded: boolean,
) => {
const actions = isAdded ? subtypeActionMap : disabledActionMap;
const map = actions.find((value) => value.subtype === subtype);
if (map) {
return map.actions.includes(actionName);
}
return false;
};
export const isSubtypeAction: ActionPredicateFn = function (action) {
return isSubtypeActionName(action.name) && !isSubtypeName(action.name);
};
export const subtypeActionPredicate: ActionPredicateFn = function (
action,
elements,
appState,
) {
// We always enable subtype actions. Also let through standard actions
// which no subtypes might have disabled.
if (
isSubtypeName(action.name) ||
(!isSubtypeActionName(action.name) && !isDisabledActionName(action.name))
) {
return true;
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const chosen = appState.editingElement
? [appState.editingElement, ...selectedElements]
: selectedElements;
// Now handle actions added by subtypes
if (isSubtypeActionName(action.name)) {
// Has any ExcalidrawElement enabled this actionName through having
// its subtype?
return (
chosen.some((el) => {
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return isForSubtype(e.subtype, action.name, true);
}) ||
// Or has any active subtype enabled this actionName?
(appState.activeSubtypes !== undefined &&
appState.activeSubtypes?.some((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return false;
}
return isForSubtype(subtype, action.name, true);
})) ||
alwaysEnabledMap.some((value) => {
return value.actions.includes(action.name);
})
);
}
// Now handle standard actions disabled by subtypes
if (isDisabledActionName(action.name)) {
return (
// Has every ExcalidrawElement not disabled this actionName?
(chosen.every((el) => {
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return !isForSubtype(e.subtype, action.name, false);
}) &&
// And has every active subtype not disabled this actionName?
(appState.activeSubtypes === undefined ||
appState.activeSubtypes?.every((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return true;
}
return !isForSubtype(subtype, action.name, false);
}))) ||
// Or can we find an ExcalidrawElement without a valid subtype
// which would disable this action if it had a valid subtype?
chosen.some((el) => {
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return parentTypeMap.some(
(value) =>
value.parentType === e.type &&
!isValidSubtype(e.subtype, e.type) &&
isForSubtype(value.subtype, action.name, false),
);
}) ||
chosen.some((el) => {
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return (
// Would the subtype of e by inself disable this action?
isForSubtype(e.subtype, action.name, false) &&
// Can we find an ExcalidrawElement which could have the same subtype
// as e but whose subtype does not disable this action?
chosen.some((el) => {
const e2 = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
return (
// Does e have a valid subtype whose parent types include the
// type of e2, and does the subtype of e2 not disable this action?
parentTypeMap
.filter((val) => val.subtype === e.subtype)
.some((val) => val.parentType === e2.type) &&
!isForSubtype(e2.subtype, action.name, false)
);
})
);
})
);
}
// Shouldn't happen
return true;
};
// Are any of the parent types of `subtype` shared by any subtype
// in the array?
export const subtypeCollides = (subtype: Subtype, subtypeArray: Subtype[]) => {
const subtypeParents = parentTypeMap
.filter((value) => value.subtype === subtype)
.map((value) => value.parentType);
const subtypeArrayParents = subtypeArray.flatMap((s) =>
parentTypeMap
.filter((value) => value.subtype === s)
.map((value) => value.parentType),
);
return subtypeParents.some((t) => subtypeArrayParents.includes(t));
};
// Subtype Methods
export type SubtypeMethods = {
clean: (
updates: Omit<
Partial<ExcalidrawElement>,
"id" | "version" | "versionNonce"
>,
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
ensureLoaded: (callback?: () => void) => Promise<void>;
measureText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => { width: number; height: number; baseline: number };
render: (
element: NonDeleted<ExcalidrawElement>,
context: CanvasRenderingContext2D,
) => void;
renderSvg: (
svgRoot: SVGElement,
root: SVGElement,
element: NonDeleted<ExcalidrawElement>,
opt?: { offsetX?: number; offsetY?: number },
) => void;
wrapText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>,
containerWidth: number,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => string;
};
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
const methodMaps = [] as Array<MethodMap>;
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
export const getSubtypeMethods = (
subtype: Subtype | undefined,
): Partial<SubtypeMethods> | undefined => {
const map = methodMaps.find((method) => method.subtype === subtype);
return map?.methods;
};
export const addSubtypeMethods = (
subtype: Subtype,
methods: Partial<SubtypeMethods>,
) => {
if (!methodMaps.find((method) => method.subtype === subtype)) {
methodMaps.push({ subtype, methods });
}
};
// For a given `ExcalidrawElement` type, return the active subtype
// and associated customData (if any) from the AppState. Assume
// only one subtype is active for a given `ExcalidrawElement` type
// at any given time.
export const selectSubtype = (
appState: {
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
},
type: ExcalidrawElement["type"],
): {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
} => {
if (appState.activeSubtypes === undefined) {
return {};
}
const subtype = appState.activeSubtypes.find((subtype) =>
isValidSubtype(subtype, type),
);
if (subtype === undefined) {
return {};
}
if (appState.customData === undefined || !(subtype in appState.customData)) {
return { subtype };
}
const customData = appState.customData[subtype];
return { subtype, customData };
};
// Callback to re-render subtyped `ExcalidrawElement`s after completing
// async loading of the subtype.
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
// Functions to prepare subtypes for use
export type SubtypePrepFn = (
addSubtypeAction: (action: Action) => void,
addLangData: (
fallbackLangData: Object,
setLanguageAux: (langCode: string) => Promise<Object | undefined>,
) => void,
onSubtypeLoaded?: SubtypeLoadedCb,
) => {
actions: Action[];
methods: Partial<SubtypeMethods>;
};
// This is the main method to set up the subtype. The optional
// `onSubtypeLoaded` callback may be used to re-render subtyped
// `ExcalidrawElement`s after the subtype has finished async loading.
// See the MathJax extension in `@excalidraw/extensions` for example.
export const prepareSubtype = (
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
onSubtypeLoaded?: SubtypeLoadedCb,
): { actions: Action[] | null; methods: Partial<SubtypeMethods> } => {
const map = getSubtypeMethods(record.subtype);
if (map) {
return { actions: null, methods: map };
}
// Check for undefined/null subtypes and parentTypes
if (
record.subtype === undefined ||
record.subtype === "" ||
record.parents === undefined ||
record.parents.length === 0
) {
return { actions: null, methods: {} };
}
// Register the types
const subtype = record.subtype;
subtypeNames = [...subtypeNames, subtype];
record.parents.forEach((parentType) => {
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
});
if (record.actionNames) {
subtypeActionMap = [
...subtypeActionMap,
{ subtype, actions: record.actionNames },
];
}
if (record.disabledNames) {
disabledActionMap = [
...disabledActionMap,
{ subtype, actions: record.disabledNames },
];
}
if (record.alwaysEnabledNames) {
alwaysEnabledMap = [
...alwaysEnabledMap,
{ subtype, actions: record.alwaysEnabledNames },
];
}
if (record.shortcutMap) {
registerCustomShortcuts(record.shortcutMap);
}
// Prepare the subtype
const { actions, methods } = subtypePrepFn(
addSubtypeAction,
registerAuxLangData,
onSubtypeLoaded,
);
// Register the subtype's methods
addSubtypeMethods(record.subtype, methods);
return { actions, methods };
};
// Ensure all subtypes are loaded before continuing, eg to
// render SVG previews of new charts. Chart-relevant subtypes
// include math equations in titles or non hand-drawn line styles.
export const ensureSubtypesLoadedForElements = async (
elements: readonly ExcalidrawElement[],
callback?: () => void,
) => {
// Only ensure the loading of subtypes which are actually needed.
// We don't want to be held up by eg downloading the MathJax SVG fonts
// if we don't actually need them yet.
const subtypesUsed = [] as Subtype[];
elements.forEach((el) => {
if (
"subtype" in el &&
isValidSubtype(el.subtype, el.type) &&
!subtypesUsed.includes(el.subtype)
) {
subtypesUsed.push(el.subtype);
}
});
await ensureSubtypesLoaded(subtypesUsed, callback);
};
export const ensureSubtypesLoaded = async (
subtypes: Subtype[],
callback?: () => void,
) => {
// Use a for loop so we can do `await map.ensureLoaded()`
for (let i = 0; i < subtypes.length; i++) {
const subtype = subtypes[i];
// Should be defined if prepareSubtype() has run
const map = getSubtypeMethods(subtype);
if (map?.ensureLoaded) {
await map.ensureLoaded();
}
}
if (callback) {
callback();
}
};
// Call this method after finishing any async loading for
// subtypes of ExcalidrawElement if the newly loaded code
// would change the rendering.
export const checkRefreshOnSubtypeLoad = (
hasSubtype: SubtypeCheckFn,
elements: readonly ExcalidrawElement[],
) => {
let refreshNeeded = false;
const scenes: Scene[] = [];
getNonDeletedElements(elements).forEach((element) => {
// If the element is of the subtype that was just
// registered, update the element's dimensions, mark the
// element for a re-render, and indicate the scene needs a refresh.
if (hasSubtype(element)) {
ShapeCache.delete(element);
if (isTextElement(element)) {
redrawTextBoundingBox(element, getContainerElement(element));
}
refreshNeeded = true;
const scene = Scene.getScene(element);
if (scene && !scenes.includes(scene)) {
// Store in case we have multiple scenes
scenes.push(scene);
}
}
});
// Only inform each scene once
scenes.forEach((scene) => scene.informMutation());
return refreshNeeded;
};
export const useSubtype = (
api: ExcalidrawImperativeAPI | null,
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
) => {
useEffect(() => {
if (api) {
const prep = api.addSubtype(record, subtypePrepFn);
if (prep) {
addSubtypeMethods(record.subtype, prep.methods);
}
}
}, [api, record, subtypePrepFn]);
};
+13
View File
@@ -0,0 +1,13 @@
import { Theme } from "../../../element/types";
import { createIcon, iconFillColor } from "../../../components/icons";
// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
export const mathSubtypeIcon = ({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
// fa-square-root-variable-solid
d="M289 24.2C292.5 10 305.3 0 320 0H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H345L239 487.8c-3.2 13-14.2 22.6-27.6 24s-26.1-5.5-32.1-17.5L76.2 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H96c12.1 0 23.2 6.8 28.6 17.7l73.3 146.6L289 24.2zM393.4 233.4c12.5-12.5 32.8-12.5 45.3 0L480 274.7l41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L525.3 320l41.4 41.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L480 365.3l-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 320l-41.4-41.4c-12.5-12.5-12.5-32.8 0-45.3z"
/>,
{ width: 576, height: 512, mirror: true, strokeWidth: 1.25 },
);
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
import { ExcalidrawImperativeAPI } from "../../../types";
import { useSubtype } from "../";
import { getMathSubtypeRecord } from "./types";
import { prepareMathSubtype } from "./implementation";
declare global {
module SREfeature {
function custom(locale: string): Promise<string>;
}
}
// The main hook to use the MathJax subtype
export const useMathSubtype = (api: ExcalidrawImperativeAPI | null) => {
useSubtype(api, getMathSubtypeRecord(), prepareMathSubtype);
};
@@ -0,0 +1,15 @@
{
"labels": {
"changeMathOnly": "Math display",
"mathOnlyTrue": "Math only",
"mathOnlyFalse": "Mixed text",
"resetUseTex": "Reset math input type",
"useTexTrueActive": "✔ Standard input",
"useTexTrueInactive": "Standard input",
"useTexFalseActive": "✔ Simplified input",
"useTexFalseInactive": "Simplified input"
},
"toolBar": {
"math": "Math"
}
}
@@ -0,0 +1,77 @@
import { vi } from "vitest";
import { render } from "../../../../tests/test-utils";
import { API } from "../../../../tests/helpers/api";
import { Excalidraw } from "../../../../packages/excalidraw/index";
import { measureTextElement } from "../../../textElement";
import { ensureSubtypesLoaded } from "../../";
import { getMathSubtypeRecord } from "../types";
import { prepareMathSubtype } from "../implementation";
describe("mathjax loaded", () => {
beforeEach(async () => {
await render(<Excalidraw />);
API.addSubtype(getMathSubtypeRecord(), prepareMathSubtype);
await ensureSubtypesLoaded(["math"]);
});
it("text-only measurements match", async () => {
const text = "A quick brown fox jumps over the lazy dog.";
const elements = [
API.createElement({ type: "text", id: "A", text, subtype: "math" }),
API.createElement({ type: "text", id: "B", text }),
];
const metrics1 = measureTextElement(elements[0]);
const metrics2 = measureTextElement(elements[1]);
expect(metrics1).toStrictEqual(metrics2);
});
it("minimum height remains", async () => {
const elements = [
API.createElement({ type: "text", id: "A", text: "a" }),
API.createElement({
type: "text",
id: "B",
text: "\\(\\alpha\\)",
subtype: "math",
customData: { useTex: true },
}),
API.createElement({
type: "text",
id: "C",
text: "`beta`",
subtype: "math",
customData: { useTex: false },
}),
];
const height = measureTextElement(elements[0]).height;
const height1 = measureTextElement(elements[1]).height;
const height2 = measureTextElement(elements[2]).height;
expect(height).toEqual(height1);
expect(height).toEqual(height2);
});
it("converts math to svgs", async () => {
const svgDim = 42;
vi.spyOn(SVGElement.prototype, "getBoundingClientRect").mockImplementation(
() => new DOMRect(0, 0, svgDim, svgDim),
);
const elements = [];
const type = "text";
const subtype = "math";
let text = "Math ";
elements.push(API.createElement({ type, text }));
text = "Math \\(\\alpha\\)";
elements.push(
API.createElement({ type, subtype, text, customData: { useTex: true } }),
);
text = "Math `beta`";
elements.push(
API.createElement({ type, subtype, text, customData: { useTex: false } }),
);
const metrics = {
width: measureTextElement(elements[0]).width + svgDim,
height: svgDim,
baseline: 0,
};
expect(measureTextElement(elements[1])).toStrictEqual(metrics);
expect(measureTextElement(elements[2])).toStrictEqual(metrics);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { getShortcutKey } from "../../../utils";
import { SubtypeRecord } from "../";
// Exports
export const getMathSubtypeRecord = () => mathSubtype;
// Use `getMathSubtype` so we don't have to export this
const mathSubtype: SubtypeRecord = {
subtype: "math",
parents: ["text"],
actionNames: ["useTexTrue", "useTexFalse", "resetUseTex", "changeMathOnly"],
disabledNames: ["changeFontFamily"],
shortcutMap: {
resetUseTex: [getShortcutKey("Shift+R")],
},
alwaysEnabledNames: ["useTexTrue", "useTexFalse"],
};
+39 -20
View File
@@ -1,3 +1,4 @@
import { getSubtypeMethods, SubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
@@ -36,6 +37,30 @@ import {
} from "./textWysiwyg";
import { ExtractSetType } from "../utility-types";
export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, element.lineHeight);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.wrapText) {
return map.wrapText(element, containerWidth, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.originalText;
return wrapText(text, font, containerWidth);
} as SubtypeMethods["wrapText"];
export const normalizeText = (text: string) => {
return (
text
@@ -68,22 +93,24 @@ export const redrawTextBoundingBox = (
if (container) {
maxWidth = getBoundTextMaxWidth(container, textElement);
boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
);
const metrics = measureTextElement(textElement, {
text: boundTextUpdates.text,
});
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
}
}
if (container) {
const maxContainerHeight = getBoundTextMaxHeight(
container,
@@ -196,17 +223,9 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
const metrics = measureTextElement(textElement, { text });
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
+5 -5
View File
@@ -1,5 +1,5 @@
import ReactDOM from "react-dom";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { GlobalTestState, render, screen } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { CODES, KEYS } from "../keys";
@@ -41,7 +41,7 @@ describe("textWysiwyg", () => {
describe("start text editing", () => {
const { h } = window;
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
});
@@ -243,7 +243,7 @@ describe("textWysiwyg", () => {
});
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
//@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
@@ -477,7 +477,7 @@ describe("textWysiwyg", () => {
const { h } = window;
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
rectangle = UI.createElement("rectangle", {
@@ -1511,7 +1511,7 @@ describe("textWysiwyg", () => {
});
it("should bump the version of labelled arrow when label updated", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
+57 -7
View File
@@ -26,6 +26,7 @@ import {
getContainerElement,
getTextElementAngle,
getTextWidth,
measureText,
normalizeText,
redrawTextBoundingBox,
wrapText,
@@ -43,8 +44,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
import { SubtypeMethods, getSubtypeMethods } from "./subtypes";
const getTransform = (
offsetX: number,
width: number,
height: number,
angle: number,
@@ -62,7 +65,8 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2;
}
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
const offset = offsetX !== 0 ? ` translate(${offsetX}px, 0px)` : "";
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)${offset}`;
};
const originalContainerCache: {
@@ -97,6 +101,14 @@ export const getOriginalContainerHeightFromCache = (
return originalContainerCache[id]?.height ?? null;
};
const getEditorStyle = function (element) {
const map = getSubtypeMethods(element.subtype);
if (map?.getEditorStyle) {
return map.getEditorStyle(element);
}
return {};
} as SubtypeMethods["getEditorStyle"];
export const textWysiwyg = ({
id,
onChange,
@@ -156,11 +168,24 @@ export const textWysiwyg = ({
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width;
// Editing metrics
const eMetrics = measureText(
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
getBoundTextMaxWidth(container),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
let maxHeight = eMetrics.height;
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
// Set to element height by default since that's
// what is going to be used for unbounded text
const textElementHeight = updatedTextElement.height;
const textElementHeight = Math.max(updatedTextElement.height, maxHeight);
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
@@ -246,13 +271,35 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff;
}
let transformWidth = updatedTextElement.width;
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
transformWidth += 0.5;
}
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
const offWidth = container
? Math.min(
0,
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
)
: Math.min(maxWidth, updatedTextElement.width) -
Math.min(maxWidth, eMetrics.width);
const offsetX =
textAlign === "right"
? offWidth
: textAlign === "center"
? offWidth / 2
: 0;
const { width: w, height: h } = updatedTextElement;
const transformOrigin =
updatedTextElement.width !== eMetrics.width ||
updatedTextElement.height !== eMetrics.height
? { transformOrigin: `${w / 2}px ${h / 2}px` }
: {};
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
@@ -270,13 +317,15 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight,
width: `${textElementWidth}px`,
width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
...transformOrigin,
transform: getTransform(
textElementWidth,
textElementHeight,
offsetX,
transformWidth,
updatedTextElement.height,
getTextElementAngle(updatedTextElement),
appState,
maxWidth,
@@ -334,6 +383,7 @@ export const textWysiwyg = ({
whiteSpace,
overflowWrap: "break-word",
boxSizing: "content-box",
...getEditorStyle(element),
});
editable.value = element.originalText;
updateWysiwygStyle();
+1
View File
@@ -65,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number;
link: string | null;
locked: boolean;
subtype?: string;
customData?: Record<string, any>;
}>;
+2
View File
@@ -116,3 +116,5 @@ declare namespace jest {
toBeNonNaNNumber(): void;
}
}
declare module "mathjax-full/mjs/input/asciimath/legacy/MathJax";
+7
View File
@@ -0,0 +1,7 @@
import { useRef } from "react";
export const useStable = <T extends Record<string, any>>(value: T) => {
const ref = useRef<T>(value);
Object.assign(ref.current, value);
return ref.current;
};
+37 -1
View File
@@ -87,6 +87,22 @@ if (import.meta.env.DEV) {
let currentLang: Language = defaultLang;
let currentLangData = {};
const auxCurrentLangData = Array<Object>();
const auxFallbackLangData = Array<Object>();
const auxSetLanguageFuncs =
Array<(langCode: string) => Promise<Object | undefined>>();
export const registerAuxLangData = (
fallbackLangData: Object,
setLanguageAux: (langCode: string) => Promise<Object | undefined>,
) => {
if (auxFallbackLangData.includes(fallbackLangData)) {
return;
}
auxFallbackLangData.push(fallbackLangData);
auxSetLanguageFuncs.push(setLanguageAux);
};
export const setLanguage = async (lang: Language) => {
currentLang = lang;
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
@@ -99,6 +115,17 @@ export const setLanguage = async (lang: Language) => {
currentLangData = await import(
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
);
// Empty the auxCurrentLangData array
while (auxCurrentLangData.length > 0) {
auxCurrentLangData.pop();
}
// Fill the auxCurrentLangData array with each locale file found in auxLangDataRoots for this language
auxSetLanguageFuncs.forEach(async (setLanguageFn) => {
const condData = await setLanguageFn(currentLang.code);
if (condData) {
auxCurrentLangData.push(condData);
}
});
} catch (error: any) {
console.error(`Failed to load language ${lang.code}:`, error.message);
currentLangData = fallbackLangData;
@@ -125,7 +152,9 @@ const findPartsForData = (data: any, parts: string[]) => {
};
export const t = (
path: NestedKeyOf<typeof fallbackLangData>,
path:
| NestedKeyOf<typeof fallbackLangData>
| `${NestedKeyOf<typeof fallbackLangData>}.${string}`,
replacement?: { [key: string]: string | number } | null,
fallback?: string,
) => {
@@ -141,6 +170,13 @@ export const t = (
findPartsForData(currentLangData, parts) ||
findPartsForData(fallbackLangData, parts) ||
fallback;
const auxData = Array<Object>().concat(
auxCurrentLangData,
auxFallbackLangData,
);
for (let i = 0; i < auxData.length; i++) {
translation = translation || findPartsForData(auxData[i], parts);
}
if (translation === undefined) {
const errorMessage = `Can't find translation for ${path}`;
// in production, don't blow up the app on a missing translation key
+2 -2
View File
@@ -1,9 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./excalidraw-app";
import ExcalidrawApp from "../excalidraw-app";
import { registerSW } from "virtual:pwa-register";
import "./excalidraw-app/sentry";
import "../excalidraw-app/sentry";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement);
+1
View File
@@ -13,6 +13,7 @@ Please add the latest change on the top under the correct section.
## 0.16.0 (2023-09-19)
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
+12
View File
@@ -31,6 +31,7 @@ import {
InteractiveCanvasAppState,
} from "../types";
import { getDefaultAppState } from "../appState";
import { getSubtypeMethods } from "../element/subtypes";
import {
BOUND_TEXT_PADDING,
FRAME_STYLE,
@@ -264,6 +265,12 @@ const drawElementOnCanvas = (
) => {
context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
const map = getSubtypeMethods(element.subtype);
if (map?.render) {
map.render(element, context);
context.globalAlpha = 1;
return;
}
switch (element.type) {
case "rectangle":
case "embeddable":
@@ -897,6 +904,11 @@ export const renderElementToSvg = (
root = anchorTag;
}
const map = getSubtypeMethods(element.subtype);
if (map?.renderSvg) {
map.renderSvg(svgRoot, root, element, { offsetX, offsetY });
return;
}
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@@ -3,7 +3,7 @@ import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { render, queryByTestId } from "../tests/test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { vi } from "vitest";
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
@@ -35,7 +35,7 @@ describe("Test <App/>", () => {
};
};
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
expect(
queryByTestId(
document.querySelector(".excalidraw-modal-container")!,
+50
View File
@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
<div
data-testid="brave-measure-text-error"
>
<p>
Looks like you are using Brave browser with the
<span
style="font-weight: 600;"
>
Aggressively Block Fingerprinting
</span>
setting enabled.
</p>
<p>
This could result in breaking the
<span
style="font-weight: 600;"
>
Text Elements
</span>
in your drawings.
</p>
<p>
We strongly recommend disabling this setting. You can follow
<a
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
>
these steps
</a>
on how to do so.
</p>
<p>
If disabling this setting doesn't fix the display of text elements, please open an
<a
href="https://github.com/excalidraw/excalidraw/issues/new"
>
issue
</a>
on our GitHub, or write us on
<a
href="https://discord.gg/UexuTaE"
>
Discord
.
</a>
</p>
</div>
`;
@@ -13089,126 +13089,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] number of elemen
exports[`regression tests > pinch-to-zoom works > [end of test] number of renders 1`] = `7`;
exports[`regression tests > rerenders UI on language change > [end of test] appState 1`] = `
{
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"lastActiveTool": null,
"locked": false,
"type": "rectangle",
},
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "hachure",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "round",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 1,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportScale": 1,
"exportWithDarkMode": false,
"fileHandle": null,
"frameRendering": {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"offsetLeft": 0,
"offsetTop": 0,
"openDialog": null,
"openMenu": "canvas",
"openPopup": null,
"openSidebar": null,
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"pendingImageElementId": null,
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
"selectedLinearElement": null,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
"toast": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"width": 1024,
"zenModeEnabled": false,
"zoom": {
"value": 1,
},
}
`;
exports[`regression tests > rerenders UI on language change > [end of test] history 1`] = `
{
"recording": false,
"redoStack": [],
"stateHistory": [
{
"appState": {
"editingGroupId": null,
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": {},
"selectedGroupIds": {},
"viewBackgroundColor": "#ffffff",
},
"elements": [],
},
],
}
`;
exports[`regression tests > rerenders UI on language change > [end of test] number of elements 1`] = `0`;
exports[`regression tests > rerenders UI on language change > [end of test] number of renders 1`] = `5`;
exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] appState 1`] = `
{
"activeEmbeddable": null,
@@ -1,4 +1,4 @@
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { CODES } from "../keys";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
@@ -9,7 +9,7 @@ import {
screen,
togglePopover,
} from "../tests/test-utils";
import { copiedStyles } from "./actionStyles";
import { copiedStyles } from "../actions/actionStyles";
const { h } = window;
@@ -17,7 +17,7 @@ const mouse = new Pointer("mouse");
describe("actionStyles", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
afterEach(async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import ReactDOM from "react-dom";
import { render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../../src/packages/excalidraw/index";
import { defaultLang, setLanguage } from "../i18n";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { API } from "./helpers/api";
@@ -60,7 +60,7 @@ describe("aligning", () => {
mouse.reset();
await setLanguage(defaultLang);
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("aligns two objects correctly to the top", () => {
+21 -16
View File
@@ -1,6 +1,6 @@
import { queryByTestId, render, waitFor } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
@@ -14,14 +14,17 @@ describe("appState", () => {
const defaultAppState = getDefaultAppState();
const exportBackground = !defaultAppState.exportBackground;
await render(<ExcalidrawApp />, {
localStorageData: {
appState: {
exportBackground,
viewBackgroundColor: "#F00",
},
},
});
await render(
<Excalidraw
initialData={{
appState: {
exportBackground,
viewBackgroundColor: "#F00",
},
}}
/>,
{},
);
await waitFor(() => {
expect(h.state.exportBackground).toBe(exportBackground);
@@ -53,13 +56,15 @@ describe("appState", () => {
});
it("changing fontSize with text tool selected (no element created yet)", async () => {
const { container } = await render(<ExcalidrawApp />, {
localStorageData: {
appState: {
currentItemFontSize: 30,
},
},
});
const { container } = await render(
<Excalidraw
initialData={{
appState: {
currentItemFontSize: 30,
},
}}
/>,
);
UI.clickTool("text");
+2 -2
View File
@@ -1,5 +1,5 @@
import { fireEvent, render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../../src/packages/excalidraw/index";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles";
import { API } from "./helpers/api";
@@ -12,7 +12,7 @@ const mouse = new Pointer("mouse");
describe("element binding", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should create valid binding if duplicate start/end points", async () => {
+8 -4
View File
@@ -7,7 +7,7 @@ import {
createPasteEvent,
} from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { KEYS } from "../keys";
import {
getDefaultLineHeight,
@@ -79,8 +79,13 @@ beforeEach(async () => {
mouse.reset();
await render(<ExcalidrawApp />);
h.app.setAppState({ zoom: { value: 1 as NormalizedZoomValue } });
await render(
<Excalidraw
autoFocus={true}
handleKeyboardGlobally={true}
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
/>,
);
setClipboardText("");
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
@@ -91,7 +96,6 @@ describe("general paste behavior", () => {
it("should randomize seed on paste", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
+5 -8
View File
@@ -11,7 +11,7 @@ import {
waitFor,
togglePopover,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
@@ -20,7 +20,6 @@ import { ShortcutName } from "../actions/shortcuts";
import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
import { LibraryItem } from "../types";
import { vi } from "vitest";
const checkpoint = (name: string) => {
@@ -56,7 +55,7 @@ describe("contextMenu element", () => {
reseed(7);
setDateTimeForTests("201933152653");
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
beforeAll(() => {
@@ -394,11 +393,9 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu!, "Add to library")!);
await waitFor(() => {
const library = localStorage.getItem("excalidraw-library");
expect(library).not.toBeNull();
const addedElement = JSON.parse(library!)[0] as LibraryItem;
expect(addedElement.elements[0]).toEqual(h.elements[0]);
await waitFor(async () => {
const libraryItems = await h.app.library.getLatestLibrary();
expect(libraryItems[0].elements[0]).toEqual(h.elements[0]);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { ExcalidrawElement } from "../element/types";
import { getShortcutKey } from "../utils";
import { API } from "./helpers/api";
import { render } from "./test-utils";
import { Excalidraw } from "../packages/excalidraw/index";
import {
CustomShortcutName,
getShortcutFromShortcutName,
registerCustomShortcuts,
} from "../actions/shortcuts";
import { Action, ActionPredicateFn, ActionResult } from "../actions/types";
import {
actionChangeFontFamily,
actionChangeFontSize,
} from "../actions/actionProperties";
import { isTextElement } from "../element";
const { h } = window;
describe("regression tests", () => {
it("should retrieve custom shortcuts", () => {
const shortcuts: Record<CustomShortcutName, string[]> = {
test: [getShortcutKey("CtrlOrCmd+1"), getShortcutKey("CtrlOrCmd+2")],
};
registerCustomShortcuts(shortcuts);
expect(getShortcutFromShortcutName("test")).toBe("Ctrl+1");
});
it("should apply universal action predicates", async () => {
await render(<Excalidraw />);
// Create the test elements
const el1 = API.createElement({ type: "rectangle", id: "A", y: 0 });
const el2 = API.createElement({ type: "rectangle", id: "B", y: 30 });
const el3 = API.createElement({ type: "text", id: "C", y: 60 });
const el12: ExcalidrawElement[] = [el1, el2];
const el13: ExcalidrawElement[] = [el1, el3];
const el23: ExcalidrawElement[] = [el2, el3];
const el123: ExcalidrawElement[] = [el1, el2, el3];
// Set up the custom Action enablers
const enableName = "custom" as Action["name"];
const enableAction: Action = {
name: enableName,
perform: (): ActionResult => {
return {} as ActionResult;
},
trackEvent: false,
};
const enabler: ActionPredicateFn = function (action, elements) {
if (action.name !== enableName || elements.some((el) => el.y === 30)) {
return true;
}
return false;
};
// Set up the standard Action disablers
const disabled1 = actionChangeFontFamily;
const disabled2 = actionChangeFontSize;
const disabler: ActionPredicateFn = function (action, elements) {
if (
action.name === disabled2.name &&
elements.some((el) => el.y === 0 || isTextElement(el))
) {
return false;
}
return true;
};
// Test the custom Action enablers
const am = h.app.actionManager;
am.registerActionPredicate(enabler);
expect(am.isActionEnabled(enableAction, { elements: el12 })).toBe(true);
expect(am.isActionEnabled(enableAction, { elements: el13 })).toBe(false);
expect(am.isActionEnabled(enableAction, { elements: el23 })).toBe(true);
expect(am.isActionEnabled(disabled1, { elements: el12 })).toBe(true);
expect(am.isActionEnabled(disabled1, { elements: el13 })).toBe(true);
expect(am.isActionEnabled(disabled1, { elements: el23 })).toBe(true);
// Test the standard Action disablers
am.registerActionPredicate(disabler);
expect(am.isActionEnabled(disabled1, { elements: el123 })).toBe(true);
expect(am.isActionEnabled(disabled2, { elements: [el1] })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: [el2] })).toBe(true);
expect(am.isActionEnabled(disabled2, { elements: [el3] })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: el12 })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: el23 })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: el13 })).toBe(false);
});
});
+15 -11
View File
@@ -1,5 +1,5 @@
import ReactDOM from "react-dom";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys";
import {
@@ -30,7 +30,7 @@ const { h } = window;
describe("Test dragCreate", () => {
describe("add element to the scene when pointer dragging long enough", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
@@ -62,7 +62,7 @@ describe("Test dragCreate", () => {
});
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
@@ -95,7 +95,7 @@ describe("Test dragCreate", () => {
});
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
@@ -127,7 +127,7 @@ describe("Test dragCreate", () => {
});
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
@@ -163,7 +163,7 @@ describe("Test dragCreate", () => {
});
it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
@@ -207,7 +207,7 @@ describe("Test dragCreate", () => {
});
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
@@ -227,7 +227,7 @@ describe("Test dragCreate", () => {
});
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
@@ -247,7 +247,7 @@ describe("Test dragCreate", () => {
});
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
@@ -267,7 +267,9 @@ describe("Test dragCreate", () => {
});
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
@@ -292,7 +294,9 @@ describe("Test dragCreate", () => {
});
it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
+2 -2
View File
@@ -1,5 +1,5 @@
import ReactDOM from "react-dom";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { render } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { KEYS } from "../keys";
@@ -15,7 +15,7 @@ const h = window.h;
describe("element locking", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { render, waitFor } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api";
import {
encodePngMetadata,
@@ -42,7 +42,7 @@ Object.defineProperty(window, "TextDecoder", {
describe("export", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
});
it("export embedded png and reimport", async () => {
+6 -6
View File
@@ -1,14 +1,14 @@
import { render } from "./test-utils";
import { API } from "./helpers/api";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { vi } from "vitest";
const { h } = window;
describe("fitToContent", () => {
it("should zoom to fit the selected element", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
@@ -30,7 +30,7 @@ describe("fitToContent", () => {
});
it("should zoom to fit multiple elements", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
const topLeft = API.createElement({
width: 20,
@@ -61,7 +61,7 @@ describe("fitToContent", () => {
});
it("should scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
@@ -106,7 +106,7 @@ describe("fitToContent animated", () => {
});
it("should ease scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
@@ -142,7 +142,7 @@ describe("fitToContent animated", () => {
});
it("should animate the scroll but not the zoom", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
h.state.width = 50;
h.state.height = 50;
+2 -2
View File
@@ -19,7 +19,7 @@ import {
FileId,
} from "../element/types";
import { newLinearElement } from "../element";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { mutateElement } from "../element/mutateElement";
import { NormalizedZoomValue } from "../types";
import { ROUNDNESS } from "../constants";
@@ -52,7 +52,7 @@ beforeEach(async () => {
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
await render(<ExcalidrawApp />);
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
h.setState({
zoom: {
value: 1 as NormalizedZoomValue,
+41
View File
@@ -16,6 +16,16 @@ import util from "util";
import path from "path";
import { getMimeType } from "../../data/blob";
import {
SubtypeLoadedCb,
SubtypePrepFn,
SubtypeRecord,
checkRefreshOnSubtypeLoad,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../../element/subtypes";
import {
maybeGetSubtypeProps,
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
@@ -32,6 +42,26 @@ const readFile = util.promisify(fs.readFile);
const { h } = window;
export class API {
constructor() {
h.app.actionManager.registerActionPredicate(subtypeActionPredicate);
if (true) {
// Call `prepareSubtype()` here for `@excalidraw/excalidraw`-specific subtypes
}
}
static addSubtype = (record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) => {
const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => {
if (checkRefreshOnSubtypeLoad(hasSubtype, h.elements)) {
h.app.refresh();
}
};
const prep = prepareSubtype(record, subtypePrepFn, subtypeLoadedCb);
if (prep.actions) {
h.app.actionManager.registerAll(prep.actions);
}
return prep;
};
static setSelectedElements = (elements: ExcalidrawElement[]) => {
h.setState({
selectedElementIds: elements.reduce((acc, element) => {
@@ -112,6 +142,8 @@ export class API {
verticalAlign?: T extends "text"
? ExcalidrawTextElement["verticalAlign"]
: never;
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
boundElements?: ExcalidrawGenericElement["boundElements"];
containerId?: T extends "text"
? ExcalidrawTextElement["containerId"]
@@ -140,6 +172,14 @@ export class API {
const appState = h?.state || getDefaultAppState();
const custom = maybeGetSubtypeProps(
{
subtype: rest.subtype ?? selectSubtype(appState, type)?.subtype,
customData:
rest.customData ?? selectSubtype(appState, type)?.customData,
},
type,
);
const base: Omit<
ExcalidrawGenericElement,
| "id"
@@ -155,6 +195,7 @@ export class API {
| "link"
| "updated"
> = {
...custom,
x,
y,
angle: rest.angle ?? 0,
+7
View File
@@ -0,0 +1,7 @@
{
"toolBar": {
"test": "Test",
"test2": "Test 2",
"test3": "Test 3"
}
}
+22 -18
View File
@@ -1,5 +1,5 @@
import { assertSelectedElements, render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState";
@@ -13,14 +13,16 @@ const mouse = new Pointer("mouse");
describe("history", () => {
it("initializing scene should end up with single history entry", async () => {
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
zenModeEnabled: true,
},
},
});
await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
zenModeEnabled: true,
},
}}
/>,
);
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
await waitFor(() =>
@@ -60,14 +62,16 @@ describe("history", () => {
});
it("scene import via drag&drop should create new history entry", async () => {
await render(<ExcalidrawApp />, {
localStorageData: {
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
viewBackgroundColor: "#FFF",
},
},
});
await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
viewBackgroundColor: "#FFF",
},
}}
/>,
);
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
await waitFor(() =>
@@ -113,7 +117,7 @@ describe("history", () => {
});
it("undo/redo works properly with groups", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = API.createElement({ type: "rectangle", groupIds: ["A"] });
const rect2 = API.createElement({ type: "rectangle", groupIds: ["A"] });
+3 -3
View File
@@ -2,7 +2,7 @@ import { vi } from "vitest";
import { fireEvent, render, waitFor } from "./test-utils";
import { queryByTestId } from "@testing-library/react";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api";
import { MIME_TYPES } from "../constants";
import { LibraryItem, LibraryItems } from "../types";
@@ -42,7 +42,7 @@ vi.mock("../data/filesystem.ts", async (importOriginal) => {
describe("library", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
h.app.library.resetLibrary();
});
@@ -189,7 +189,7 @@ describe("library", () => {
describe("library menu", () => {
it("should load library from file picker", async () => {
const { container } = await render(<ExcalidrawApp />);
const { container } = await render(<Excalidraw />);
const latestLibrary = await h.app.library.getLatestLibrary();
expect(latestLibrary.length).toBe(0);
+2 -2
View File
@@ -5,7 +5,7 @@ import {
ExcalidrawTextElementWithContainer,
FontString,
} from "../element/types";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { centerPoint } from "../math";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
@@ -43,7 +43,7 @@ describe("Test Linear Elements", () => {
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
const comp = await render(<ExcalidrawApp />);
const comp = await render(<Excalidraw handleKeyboardGlobally={true} />);
h.state.width = 1000;
h.state.height = 1000;
container = comp.container;
+4 -4
View File
@@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { bindOrUnbindLinearElement } from "../element/binding";
@@ -31,7 +31,7 @@ const { h } = window;
describe("move element", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
const canvas = container.querySelector("canvas.interactive")!;
{
@@ -67,7 +67,7 @@ describe("move element", () => {
});
it("rectangles with binding arrow", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
// create elements
const rectA = UI.createElement("rectangle", { size: 100 });
@@ -119,7 +119,7 @@ describe("move element", () => {
describe("duplicate element on move when ALT is clicked", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
const canvas = container.querySelector("canvas.interactive")!;
{
+12 -11
View File
@@ -5,7 +5,7 @@ import {
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys";
import { ExcalidrawLinearElement } from "../element/types";
@@ -29,7 +29,7 @@ const { h } = window;
describe("remove shape in non linear elements", () => {
beforeAll(() => {
mockBoundingClientRect();
mockBoundingClientRect({ width: 1000, height: 1000 });
});
afterAll(() => {
@@ -37,12 +37,13 @@ describe("remove shape in non linear elements", () => {
});
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
@@ -52,7 +53,7 @@ describe("remove shape in non linear elements", () => {
});
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
@@ -67,7 +68,7 @@ describe("remove shape in non linear elements", () => {
});
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
@@ -84,7 +85,7 @@ describe("remove shape in non linear elements", () => {
describe("multi point mode in linear elements", () => {
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
@@ -109,8 +110,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@@ -128,7 +129,7 @@ describe("multi point mode in linear elements", () => {
});
it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
@@ -153,8 +154,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(11);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
+2 -24
View File
@@ -1,7 +1,7 @@
import ReactDOM from "react-dom";
import { ExcalidrawElement } from "../element/types";
import { CODES, KEYS } from "../keys";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
import { setDateTimeForTests } from "../utils";
@@ -13,9 +13,7 @@ import {
render,
screen,
togglePopover,
waitFor,
} from "./test-utils";
import { defaultLang } from "../i18n";
import { FONT_FAMILY } from "../constants";
import { vi } from "vitest";
@@ -56,7 +54,7 @@ beforeEach(async () => {
finger1.reset();
finger2.reset();
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.setState({ height: 768, width: 1024 });
});
@@ -443,26 +441,6 @@ describe("regression tests", () => {
expect(h.state.zoom.value).toBe(1);
});
it("rerenders UI on language change", async () => {
// 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());
});
it("make a group and duplicate it", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
+2 -2
View File
@@ -6,7 +6,7 @@ import { reseed } from "../random";
import { UI, Keyboard } from "./helpers/ui";
import { resize } from "./utils";
import { ExcalidrawTextElement } from "../element/types";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api";
import { KEYS } from "../keys";
import { vi } from "vitest";
@@ -126,7 +126,7 @@ describe("resize rectangle ellipses and diamond elements", () => {
describe("Test text element", () => {
it("should update font size via keyboard", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw handleKeyboardGlobally={true} />);
const textElement = API.createElement({
type: "text",
+1 -2
View File
@@ -8,7 +8,6 @@ import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui";
import { KEYS } from "../keys";
import ExcalidrawApp from "../excalidraw-app";
const { h } = window;
@@ -56,7 +55,7 @@ describe("appState", () => {
it("moving by page up/down/left/right", async () => {
mockBoundingClientRect();
await render(<ExcalidrawApp />, {});
await render(<Excalidraw handleKeyboardGlobally={true} />, {});
const scrollTest = () => {
const initialScrollY = h.state.scrollY;
+23 -13
View File
@@ -6,7 +6,7 @@ import {
restoreOriginalGetBoundingClientRect,
assertSelectedElements,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys";
import { reseed } from "../random";
@@ -34,7 +34,7 @@ const mouse = new Pointer("mouse");
describe("box-selection", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
});
it("should allow adding to selection via box-select when holding shift", async () => {
@@ -102,7 +102,7 @@ describe("box-selection", () => {
describe("inner box-selection", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
});
it("selecting elements visually nested inside another", async () => {
const rect1 = API.createElement({
@@ -218,7 +218,7 @@ describe("inner box-selection", () => {
describe("selection element", () => {
it("create selection element on pointer down", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("selection");
fireEvent.click(tool);
@@ -239,7 +239,7 @@ describe("selection element", () => {
});
it("resize selection element on pointer move", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("selection");
fireEvent.click(tool);
@@ -261,7 +261,7 @@ describe("selection element", () => {
});
it("remove selection element on pointer up", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(<Excalidraw />);
// select tool
const tool = getByToolName("selection");
fireEvent.click(tool);
@@ -287,7 +287,9 @@ describe("select single element on the scene", () => {
});
it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@@ -317,7 +319,9 @@ describe("select single element on the scene", () => {
});
it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@@ -347,7 +351,9 @@ describe("select single element on the scene", () => {
});
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@@ -377,7 +383,9 @@ describe("select single element on the scene", () => {
});
it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@@ -419,7 +427,9 @@ describe("select single element on the scene", () => {
});
it("arrow escape", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!;
{
// create element
@@ -464,7 +474,7 @@ describe("select single element on the scene", () => {
describe("tool locking & selection", () => {
it("should not select newly created element while tool is locked", async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
UI.clickTool("lock");
expect(h.state.activeTool.locked).toBe(true);
@@ -480,7 +490,7 @@ describe("tool locking & selection", () => {
describe("selectedElementIds stability", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
await render(<Excalidraw />);
});
it("box-selection should be stable when not changing selection", () => {
+689
View File
@@ -0,0 +1,689 @@
import { vi } from "vitest";
import fallbackLangData from "./helpers/locales/en.json";
import {
SubtypeLoadedCb,
SubtypeRecord,
SubtypeMethods,
SubtypePrepFn,
addSubtypeMethods,
ensureSubtypesLoadedForElements,
getSubtypeMethods,
getSubtypeNames,
hasAlwaysEnabledActions,
isValidSubtype,
selectSubtype,
subtypeCollides,
} from "../element/subtypes";
import { render } from "./test-utils";
import { API } from "./helpers/api";
import { Excalidraw } from "../packages/excalidraw/index";
import {
ExcalidrawElement,
ExcalidrawTextElement,
FontString,
Theme,
} from "../element/types";
import { createIcon, iconFillColor } from "../components/icons";
import { SubtypeButton } from "../components/Subtypes";
import { registerAuxLangData } from "../i18n";
import { getFontString, getShortcutKey } from "../utils";
import * as textElementUtils from "../element/textElement";
import { isTextElement } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { Action, ActionName } from "../actions/types";
import { AppState } from "../types";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionChangeSloppiness } from "../actions";
import { actionChangeRoundness } from "../actions/actionProperties";
const MW = 200;
const TWIDTH = 200;
const THEIGHT = 20;
const TBASELINE = 0;
const FONTSIZE = 20;
const DBFONTSIZE = 40;
const TRFONTSIZE = 60;
const getLangData = async (langCode: string): Promise<Object | undefined> => {
try {
const condData = await import(
/* webpackChunkName: "locales/[request]" */ `./helpers/locales/${langCode}.json`
);
if (condData) {
return condData;
}
} catch (e) {}
return undefined;
};
const testSubtypeIcon = ({ theme }: { theme: Theme }) =>
createIcon(
<path
stroke={iconFillColor(theme)}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>,
{ width: 40, height: 20, mirror: true },
);
const TEST_ACTION = "testAction";
const TEST_DISABLE1 = actionChangeSloppiness;
const TEST_DISABLE3 = actionChangeRoundness;
const test1: SubtypeRecord = {
subtype: "test",
parents: ["line", "arrow", "rectangle", "diamond", "ellipse"],
disabledNames: [TEST_DISABLE1.name as ActionName],
actionNames: [TEST_ACTION],
};
const testAction: Action = {
name: TEST_ACTION,
trackEvent: false,
perform: (elements, appState) => {
return {
elements,
commitToHistory: false,
};
},
};
const test1Button = SubtypeButton(
test1.subtype,
test1.parents[0],
testSubtypeIcon,
);
const test1NonParent = "text" as const;
const test2: SubtypeRecord = {
subtype: "test2",
parents: ["text"],
};
const test2Button = SubtypeButton(
test2.subtype,
test2.parents[0],
testSubtypeIcon,
);
const test3: SubtypeRecord = {
subtype: "test3",
parents: ["text", "line"],
shortcutMap: {
testShortcut: [getShortcutKey("Shift+T")],
},
alwaysEnabledNames: ["test3Always"],
disabledNames: [TEST_DISABLE3.name as ActionName],
};
const test3Button = SubtypeButton(
test3.subtype,
test3.parents[0],
testSubtypeIcon,
);
const cleanTestElementUpdate = function (updates) {
const oldUpdates = {};
for (const key in updates) {
if (key !== "roughness") {
(oldUpdates as any)[key] = (updates as any)[key];
}
}
(updates as any).roughness = 0;
return oldUpdates;
} as SubtypeMethods["clean"];
const prepareNullSubtype = function () {
const methods = {} as SubtypeMethods;
methods.clean = cleanTestElementUpdate;
methods.measureText = measureTest2;
methods.wrapText = wrapTest2;
const actions = [test1Button, test2Button, test3Button];
return { actions, methods };
} as SubtypePrepFn;
const prepareTest1Subtype = function (
addSubtypeAction,
addLangData,
onSubtypeLoaded,
) {
const methods = {} as SubtypeMethods;
methods.clean = cleanTestElementUpdate;
addLangData(fallbackLangData, getLangData);
registerAuxLangData(fallbackLangData, getLangData);
const actions = [testAction, test1Button];
actions.forEach((action) => addSubtypeAction(action));
return { actions, methods };
} as SubtypePrepFn;
let test2Loaded = false;
const ensureLoadedTest2: SubtypeMethods["ensureLoaded"] = async (callback) => {
test2Loaded = true;
if (onTest2Loaded) {
onTest2Loaded((el) => isTextElement(el) && el.subtype === test2.subtype);
}
if (callback) {
callback();
}
};
const measureTest2: SubtypeMethods["measureText"] = function (element, next) {
const text = next?.text ?? element.text;
const customData = next?.customData ?? {};
const fontSize = customData.triple
? TRFONTSIZE
: next?.fontSize ?? element.fontSize;
const fontFamily = element.fontFamily;
const fontString = getFontString({ fontSize, fontFamily });
const lineHeight = element.lineHeight;
const metrics = textElementUtils.measureText(text, fontString, lineHeight);
const width = test2Loaded
? metrics.width * 2
: Math.max(metrics.width - 10, 0);
const height = test2Loaded
? metrics.height * 2
: Math.max(metrics.height - 5, 0);
return { width, height, baseline: 1 };
};
const wrapTest2: SubtypeMethods["wrapText"] = function (
element,
maxWidth,
next,
) {
const text = next?.text ?? element.originalText;
if (next?.customData && next?.customData.triple === true) {
return `${text.split(" ").join("\n")}\nHELLO WORLD.`;
}
if (next?.fontSize === DBFONTSIZE) {
return `${text.split(" ").join("\n")}\nHELLO World.`;
}
return `${text.split(" ").join("\n")}\nHello world.`;
};
let onTest2Loaded: SubtypeLoadedCb | undefined;
const prepareTest2Subtype = function (
addSubtypeAction,
addLangData,
onSubtypeLoaded,
) {
const methods = {
ensureLoaded: ensureLoadedTest2,
measureText: measureTest2,
wrapText: wrapTest2,
} as SubtypeMethods;
addLangData(fallbackLangData, getLangData);
registerAuxLangData(fallbackLangData, getLangData);
const actions = [test2Button];
actions.forEach((action) => addSubtypeAction(action));
onTest2Loaded = onSubtypeLoaded;
return { actions, methods };
} as SubtypePrepFn;
const prepareTest3Subtype = function (
addSubtypeAction,
addLangData,
onSubtypeLoaded,
) {
const methods = {} as SubtypeMethods;
addLangData(fallbackLangData, getLangData);
registerAuxLangData(fallbackLangData, getLangData);
const actions = [test3Button];
actions.forEach((action) => addSubtypeAction(action));
return { actions, methods };
} as SubtypePrepFn;
const { h } = window;
describe("subtype registration", () => {
it("should check for invalid subtype or parents", async () => {
await render(<Excalidraw />, {});
// Define invalid subtype records
const null1 = {} as SubtypeRecord;
const null2 = { subtype: "" } as SubtypeRecord;
const null3 = { subtype: "null" } as SubtypeRecord;
const null4 = { subtype: "null", parents: [] } as SubtypeRecord;
// Try registering the invalid subtypes
const prepN1 = API.addSubtype(null1, prepareNullSubtype);
const prepN2 = API.addSubtype(null2, prepareNullSubtype);
const prepN3 = API.addSubtype(null3, prepareNullSubtype);
const prepN4 = API.addSubtype(null4, prepareNullSubtype);
// Verify the guards in `prepareSubtype` worked
expect(prepN1).toStrictEqual({ actions: null, methods: {} });
expect(prepN2).toStrictEqual({ actions: null, methods: {} });
expect(prepN3).toStrictEqual({ actions: null, methods: {} });
expect(prepN4).toStrictEqual({ actions: null, methods: {} });
});
it("should return subtype actions and methods correctly", async () => {
// Check initial registration works
let prep1 = API.addSubtype(test1, prepareTest1Subtype);
expect(prep1.actions).toStrictEqual([testAction, test1Button]);
expect(prep1.methods).toStrictEqual({ clean: cleanTestElementUpdate });
// Check repeat registration fails
prep1 = API.addSubtype(test1, prepareNullSubtype);
expect(prep1.actions).toBeNull();
expect(prep1.methods).toStrictEqual({ clean: cleanTestElementUpdate });
// Check initial registration works
let prep2 = API.addSubtype(test2, prepareTest2Subtype);
expect(prep2.actions).toStrictEqual([test2Button]);
expect(prep2.methods).toStrictEqual({
ensureLoaded: ensureLoadedTest2,
measureText: measureTest2,
wrapText: wrapTest2,
});
// Check repeat registration fails
prep2 = API.addSubtype(test2, prepareNullSubtype);
expect(prep2.actions).toBeNull();
expect(prep2.methods).toStrictEqual({
ensureLoaded: ensureLoadedTest2,
measureText: measureTest2,
wrapText: wrapTest2,
});
// Check initial registration works
let prep3 = API.addSubtype(test3, prepareTest3Subtype);
expect(prep3.actions).toStrictEqual([test3Button]);
expect(prep3.methods).toStrictEqual({});
// Check repeat registration fails
prep3 = API.addSubtype(test3, prepareNullSubtype);
expect(prep3.actions).toBeNull();
expect(prep3.methods).toStrictEqual({});
});
});
describe("subtypes", () => {
it("should correctly register", async () => {
const subtypes = getSubtypeNames();
expect(subtypes).toContain(test1.subtype);
expect(subtypes).toContain(test2.subtype);
expect(subtypes).toContain(test3.subtype);
});
it("should return subtype methods", async () => {
expect(getSubtypeMethods(undefined)).toBeUndefined();
const test1Methods = getSubtypeMethods(test1.subtype);
expect(test1Methods?.clean).toBeDefined();
expect(test1Methods?.render).toBeUndefined();
expect(test1Methods?.wrapText).toBeUndefined();
expect(test1Methods?.renderSvg).toBeUndefined();
expect(test1Methods?.measureText).toBeUndefined();
expect(test1Methods?.ensureLoaded).toBeUndefined();
});
it("should not overwrite subtype methods", async () => {
addSubtypeMethods(test1.subtype, {});
addSubtypeMethods(test2.subtype, {});
addSubtypeMethods(test3.subtype, { clean: cleanTestElementUpdate });
const test1Methods = getSubtypeMethods(test1.subtype);
expect(test1Methods?.clean).toBeDefined();
const test2Methods = getSubtypeMethods(test2.subtype);
expect(test2Methods?.measureText).toBeDefined();
expect(test2Methods?.wrapText).toBeDefined();
const test3Methods = getSubtypeMethods(test3.subtype);
expect(test3Methods?.clean).toBeUndefined();
});
it("should register custom shortcuts", async () => {
expect(getShortcutFromShortcutName("testShortcut")).toBe("Shift+T");
});
it("should correctly validate", async () => {
test1.parents.forEach((p) => {
expect(isValidSubtype(test1.subtype, p)).toBe(true);
expect(isValidSubtype(undefined, p)).toBe(false);
});
expect(isValidSubtype(test1.subtype, test1NonParent)).toBe(false);
expect(isValidSubtype(test1.subtype, undefined)).toBe(false);
expect(isValidSubtype(undefined, undefined)).toBe(false);
});
it("should collide with themselves", async () => {
expect(subtypeCollides(test1.subtype, [test1.subtype])).toBe(true);
expect(subtypeCollides(test1.subtype, [test1.subtype, test2.subtype])).toBe(
true,
);
});
it("should not collide without type overlap", async () => {
expect(subtypeCollides(test1.subtype, [test2.subtype])).toBe(false);
});
it("should collide with type overlap", async () => {
expect(subtypeCollides(test1.subtype, [test3.subtype])).toBe(true);
});
it("should apply to ExcalidrawElements", async () => {
const elements = [
API.createElement({ type: "line", id: "A", subtype: test1.subtype }),
API.createElement({ type: "arrow", id: "B", subtype: test1.subtype }),
API.createElement({ type: "rectangle", id: "C", subtype: test1.subtype }),
API.createElement({ type: "diamond", id: "D", subtype: test1.subtype }),
API.createElement({ type: "ellipse", id: "E", subtype: test1.subtype }),
];
await render(<Excalidraw />, { localStorageData: { elements } });
elements.forEach((el) => expect(el.subtype).toBe(test1.subtype));
});
it("should enforce prop value restrictions", async () => {
const elements = [
API.createElement({
type: "line",
id: "A",
subtype: test1.subtype,
roughness: 1,
}),
API.createElement({ type: "line", id: "B", roughness: 1 }),
];
await render(<Excalidraw />, { localStorageData: { elements } });
elements.forEach((el) => {
if (el.subtype === test1.subtype) {
expect(el.roughness).toBe(0);
} else {
expect(el.roughness).toBe(1);
}
});
});
it("should consider enforced prop values in version increments", async () => {
const rectA = API.createElement({
type: "line",
id: "A",
subtype: test1.subtype,
roughness: 1,
strokeWidth: 1,
});
const rectB = API.createElement({
type: "line",
id: "B",
subtype: test1.subtype,
roughness: 1,
strokeWidth: 1,
});
// Initial element creation checks
expect(rectA.roughness).toBe(0);
expect(rectB.roughness).toBe(0);
expect(rectA.version).toBe(1);
expect(rectB.version).toBe(1);
// Check that attempting to set prop values not permitted by the subtype
// doesn't increment element versions
mutateElement(rectA, { roughness: 2 });
mutateElement(rectB, { roughness: 2, strokeWidth: 2 });
expect(rectA.version).toBe(1);
expect(rectB.version).toBe(2);
// Check that element versions don't increment when creating new elements
// while attempting to use prop values not permitted by the subtype
// First check based on `rectA` (unsuccessfully mutated)
const rectC = newElementWith(rectA, { roughness: 1 });
const rectD = newElementWith(rectA, { roughness: 1, strokeWidth: 1.5 });
expect(rectC.version).toBe(1);
expect(rectD.version).toBe(2);
// Then check based on `rectB` (successfully mutated)
const rectE = newElementWith(rectB, { roughness: 1 });
const rectF = newElementWith(rectB, { roughness: 1, strokeWidth: 1.5 });
expect(rectE.version).toBe(2);
expect(rectF.version).toBe(3);
});
it("should call custom text methods", async () => {
const testString = "A quick brown fox jumps over the lazy dog.";
const elements = [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
text: testString,
fontSize: FONTSIZE,
}),
];
await render(<Excalidraw />, { localStorageData: { elements } });
const mockMeasureText = (text: string, font: FontString) => {
if (text === testString) {
let multiplier = 1;
if (font.includes(`${DBFONTSIZE}`)) {
multiplier = 2;
}
if (font.includes(`${TRFONTSIZE}`)) {
multiplier = 3;
}
const width = multiplier * TWIDTH;
const height = multiplier * THEIGHT;
const baseline = multiplier * TBASELINE;
return { width, height, baseline };
}
return { width: 1, height: 0, baseline: 0 };
};
vi.spyOn(textElementUtils, "measureText").mockImplementation(
mockMeasureText,
);
elements.forEach((el) => {
if (isTextElement(el)) {
// First test with `ExcalidrawTextElement.text`
const metrics = textElementUtils.measureTextElement(el);
expect(metrics).toStrictEqual({
width: TWIDTH - 10,
height: THEIGHT - 5,
baseline: TBASELINE + 1,
});
const wrappedText = textElementUtils.wrapTextElement(el, MW);
expect(wrappedText).toEqual(
`${testString.split(" ").join("\n")}\nHello world.`,
);
// Now test with modified text in `next`
let next: {
text?: string;
fontSize?: number;
customData?: Record<string, any>;
} = {
text: "Hello world.",
};
const nextMetrics = textElementUtils.measureTextElement(el, next);
expect(nextMetrics).toStrictEqual({ width: 0, height: 0, baseline: 1 });
const nextWrappedText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextWrappedText).toEqual("Hello\nworld.\nHello world.");
// Now test modified fontSizes in `next`
next = { fontSize: DBFONTSIZE };
const nextFM = textElementUtils.measureTextElement(el, next);
expect(nextFM).toStrictEqual({
width: 2 * TWIDTH - 10,
height: 2 * THEIGHT - 5,
baseline: 2 * TBASELINE + 1,
});
const nextFWrText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextFWrText).toEqual(
`${testString.split(" ").join("\n")}\nHELLO World.`,
);
// Now test customData in `next`
next = { customData: { triple: true } };
const nextCD = textElementUtils.measureTextElement(el, next);
expect(nextCD).toStrictEqual({
width: 3 * TWIDTH - 10,
height: 3 * THEIGHT - 5,
baseline: 3 * TBASELINE + 1,
});
const nextCDWrText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextCDWrText).toEqual(
`${testString.split(" ").join("\n")}\nHELLO WORLD.`,
);
}
});
});
it("should recognize subtypes with always-enabled actions", async () => {
expect(hasAlwaysEnabledActions(test1.subtype)).toBe(false);
expect(hasAlwaysEnabledActions(test2.subtype)).toBe(false);
expect(hasAlwaysEnabledActions(test3.subtype)).toBe(true);
});
it("should select active subtypes and customData", async () => {
const appState = {} as {
activeSubtypes: AppState["activeSubtypes"];
customData: AppState["customData"];
};
// No active subtypes
let subtypes = selectSubtype(appState, "text");
expect(subtypes.subtype).toBeUndefined();
expect(subtypes.customData).toBeUndefined();
// Subtype for both "text" and "line" types
appState.activeSubtypes = [test3.subtype];
subtypes = selectSubtype(appState, "text");
expect(subtypes.subtype).toBe(test3.subtype);
subtypes = selectSubtype(appState, "line");
expect(subtypes.subtype).toBe(test3.subtype);
subtypes = selectSubtype(appState, "arrow");
expect(subtypes.subtype).toBeUndefined();
// Subtype for multiple linear types
appState.activeSubtypes = [test1.subtype];
subtypes = selectSubtype(appState, "text");
expect(subtypes.subtype).toBeUndefined();
subtypes = selectSubtype(appState, "line");
expect(subtypes.subtype).toBe(test1.subtype);
subtypes = selectSubtype(appState, "arrow");
expect(subtypes.subtype).toBe(test1.subtype);
// Subtype for "text" only
appState.activeSubtypes = [test2.subtype];
subtypes = selectSubtype(appState, "text");
expect(subtypes.subtype).toBe(test2.subtype);
subtypes = selectSubtype(appState, "line");
expect(subtypes.subtype).toBeUndefined();
subtypes = selectSubtype(appState, "arrow");
expect(subtypes.subtype).toBeUndefined();
// Test customData
appState.customData = {};
appState.customData[test1.subtype] = { test: true };
appState.customData[test2.subtype] = { test2: true };
appState.customData[test3.subtype] = { test3: true };
// Subtype for both "text" and "line" types
appState.activeSubtypes = [test3.subtype];
subtypes = selectSubtype(appState, "text");
expect(subtypes.customData).toBeDefined();
expect(subtypes.customData![test1.subtype]).toBeUndefined();
expect(subtypes.customData![test2.subtype]).toBeUndefined();
expect(subtypes.customData![test3.subtype]).toBe(true);
subtypes = selectSubtype(appState, "line");
expect(subtypes.customData).toBeDefined();
expect(subtypes.customData![test1.subtype]).toBeUndefined();
expect(subtypes.customData![test2.subtype]).toBeUndefined();
expect(subtypes.customData![test3.subtype]).toBe(true);
subtypes = selectSubtype(appState, "arrow");
expect(subtypes.customData).toBeUndefined();
// Subtype for multiple linear types
appState.activeSubtypes = [test1.subtype];
subtypes = selectSubtype(appState, "text");
expect(subtypes.customData).toBeUndefined();
subtypes = selectSubtype(appState, "line");
expect(subtypes.customData).toBeDefined();
expect(subtypes.customData![test1.subtype]).toBe(true);
expect(subtypes.customData![test2.subtype]).toBeUndefined();
expect(subtypes.customData![test3.subtype]).toBeUndefined();
// Multiple, non-colliding subtypes
appState.activeSubtypes = [test1.subtype, test2.subtype];
subtypes = selectSubtype(appState, "text");
expect(subtypes.customData).toBeDefined();
expect(subtypes.customData![test1.subtype]).toBeUndefined();
expect(subtypes.customData![test2.subtype]).toBe(true);
expect(subtypes.customData![test3.subtype]).toBeUndefined();
subtypes = selectSubtype(appState, "line");
expect(subtypes.customData).toBeDefined();
expect(subtypes.customData![test1.subtype]).toBe(true);
expect(subtypes.customData![test2.subtype]).toBeUndefined();
expect(subtypes.customData![test3.subtype]).toBeUndefined();
});
});
describe("subtype actions", () => {
let elements: ExcalidrawElement[];
beforeEach(async () => {
elements = [
API.createElement({ type: "line", id: "A", subtype: test1.subtype }),
API.createElement({ type: "line", id: "B" }),
API.createElement({ type: "line", id: "C", subtype: test3.subtype }),
API.createElement({ type: "text", id: "D", subtype: test3.subtype }),
];
await render(<Excalidraw />, { localStorageData: { elements } });
});
it("should apply to elements with their subtype", async () => {
h.setState({ selectedElementIds: { A: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(true);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(false);
});
it("should apply to elements without a subtype", async () => {
h.setState({ selectedElementIds: { B: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(false);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(true);
});
it("should apply to elements with and without their subtype", async () => {
h.setState({ selectedElementIds: { A: true, B: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(true);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(true);
});
it("should apply to elements with a different subtype", async () => {
h.setState({ selectedElementIds: { C: true, D: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(false);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(true);
});
it("should apply to like types with varying subtypes", async () => {
h.setState({ selectedElementIds: { A: true, C: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(true);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(true);
});
it("should apply to non-like types with varying subtypes", async () => {
h.setState({ selectedElementIds: { A: true, D: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(true);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(false);
});
it("should apply to like/non-like types with varying subtypes", async () => {
h.setState({ selectedElementIds: { A: true, B: true, D: true } });
const am = h.app.actionManager;
expect(am.isActionEnabled(testAction, { elements })).toBe(true);
expect(am.isActionEnabled(TEST_DISABLE1, { elements })).toBe(true);
});
it("should apply to the correct parent type", async () => {
const am = h.app.actionManager;
h.setState({ selectedElementIds: { A: true, C: true } });
expect(am.isActionEnabled(TEST_DISABLE3, { elements })).toBe(true);
h.setState({ selectedElementIds: { A: true, D: true } });
expect(am.isActionEnabled(TEST_DISABLE3, { elements })).toBe(true);
});
});
describe("subtype loading", () => {
let elements: ExcalidrawElement[];
beforeEach(async () => {
const testString = "A quick brown fox jumps over the lazy dog.";
elements = [
API.createElement({
type: "text",
id: "A",
subtype: test2.subtype,
text: testString,
}),
];
await render(<Excalidraw />, { localStorageData: { elements } });
h.elements = elements;
});
it("should redraw text bounding boxes", async () => {
h.setState({ selectedElementIds: { A: true } });
const el = h.elements[0] as ExcalidrawTextElement;
expect(el.width).toEqual(100);
expect(el.height).toEqual(100);
ensureSubtypesLoadedForElements(elements);
expect(el.width).toEqual(TWIDTH * 2);
expect(el.height).toEqual(THEIGHT * 2);
expect(el.baseline).toEqual(TBASELINE + 1);
});
});

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