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 { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "../utils"; import { debounce, getVersion, nFormatter } from "../src/utils";
import { import {
getElementsStorageSize, getElementsStorageSize,
getTotalStorageSize, getTotalStorageSize,
} from "./data/localStorage"; } from "./data/localStorage";
import { DEFAULT_VERSION } from "../constants"; import { DEFAULT_VERSION } from "../src/constants";
import { t } from "../i18n"; import { t } from "../src/i18n";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../src/clipboard";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../src/element/types";
import { UIAppState } from "../types"; import { UIAppState } from "../src/types";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };
@@ -1,23 +1,23 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types"; import { ExcalidrawImperativeAPI } from "../../src/types";
import { ErrorDialog } from "../../components/ErrorDialog"; import { ErrorDialog } from "../../src/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants"; import { APP_NAME, ENV, EVENT } from "../../src/constants";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../src/data/types";
import { import {
ExcalidrawElement, ExcalidrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../element/types"; } from "../../src/element/types";
import { import {
getSceneVersion, getSceneVersion,
restoreElements, restoreElements,
} from "../../packages/excalidraw/index"; } from "../../src/packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types"; import { Collaborator, Gesture } from "../../src/types";
import { import {
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
withBatchedUpdates, withBatchedUpdates,
} from "../../utils"; } from "../../src/utils";
import { import {
CURSOR_SYNC_TIMEOUT, CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
@@ -48,25 +48,25 @@ import {
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { t } from "../../i18n"; import { t } from "../../src/i18n";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../src/types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants"; import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
import { import {
encodeFilesForUpload, encodeFilesForUpload,
FileManager, FileManager,
updateStaleImageStatuses, updateStaleImageStatuses,
} from "../data/FileManager"; } from "../data/FileManager";
import { AbortError } from "../../errors"; import { AbortError } from "../../src/errors";
import { import {
isImageElement, isImageElement,
isInitializedImageElement, isInitializedImageElement,
} from "../../element/typeChecks"; } from "../../src/element/typeChecks";
import { newElementWith } from "../../element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { import {
ReconciledElements, ReconciledElements,
reconcileElements as _reconcileElements, reconcileElements as _reconcileElements,
} from "./reconciliation"; } from "./reconciliation";
import { decryptData } from "../../data/encryption"; import { decryptData } from "../../src/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
@@ -6,19 +6,19 @@ import {
import { TCollabClass } from "./Collab"; import { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { import {
WS_EVENTS, WS_EVENTS,
FILE_UPLOAD_TIMEOUT, FILE_UPLOAD_TIMEOUT,
WS_SCENE_EVENT_TYPES, WS_SCENE_EVENT_TYPES,
} from "../app_constants"; } from "../app_constants";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../src/types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../src/analytics";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { newElementWith } from "../../element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation"; import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption"; import { encryptData } from "../../src/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../constants"; import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
class Portal { class Portal {
collab: TCollabClass; collab: TCollabClass;
@@ -1,4 +1,4 @@
@import "../../css/variables.module"; @import "../../src/css/variables.module";
.excalidraw { .excalidraw {
.RoomDialog { .RoomDialog {
@@ -1,13 +1,13 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../clipboard"; import { copyTextToSystemClipboard } from "../../src/clipboard";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../src/analytics";
import { getFrame } from "../../utils"; import { getFrame } from "../../src/utils";
import { useI18n } from "../../i18n"; import { useI18n } from "../../src/i18n";
import { KEYS } from "../../keys"; import { KEYS } from "../../src/keys";
import { Dialog } from "../../components/Dialog"; import { Dialog } from "../../src/components/Dialog";
import { import {
copyIcon, copyIcon,
playerPlayIcon, playerPlayIcon,
@@ -16,11 +16,11 @@ import {
shareIOS, shareIOS,
shareWindows, shareWindows,
tablerCheckIcon, tablerCheckIcon,
} from "../../components/icons"; } from "../../src/components/icons";
import { TextField } from "../../components/TextField"; import { TextField } from "../../src/components/TextField";
import { FilledButton } from "../../components/FilledButton"; 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"; import "./RoomDialog.scss";
const getShareIcon = () => { const getShareIcon = () => {
@@ -1,7 +1,7 @@
import { PRECEDING_ELEMENT_KEY } from "../../constants"; import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../types"; import { AppState } from "../../src/types";
import { arrayToMapWithIndex } from "../../utils"; import { arrayToMapWithIndex } from "../../src/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & { export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements"; _brand: "reconciledElements";
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Footer } from "../../packages/excalidraw/index"; import { Footer } from "../../src/packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../components/icons"; import { PlusPromoIcon } from "../../src/components/icons";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../src/packages/excalidraw/index";
import { LanguageList } from "./LanguageList"; import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../components/icons"; import { PlusPromoIcon } from "../../src/components/icons";
import { useI18n } from "../../i18n"; import { useI18n } from "../../src/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index"; import { WelcomeScreen } from "../../src/packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "../../constants"; import { POINTER_EVENTS } from "../../src/constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
@@ -1,6 +1,6 @@
import { shield } from "../../components/icons"; import { shield } from "../../src/components/icons";
import { Tooltip } from "../../components/Tooltip"; import { Tooltip } from "../../src/components/Tooltip";
import { useI18n } from "../../i18n"; import { useI18n } from "../../src/i18n";
export const EncryptedIcon = () => { export const EncryptedIcon = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -1,20 +1,20 @@
import React from "react"; import React from "react";
import { Card } from "../../components/Card"; import { Card } from "../../src/components/Card";
import { ToolButton } from "../../components/ToolButton"; import { ToolButton } from "../../src/components/ToolButton";
import { serializeAsJSON } from "../../data/json"; import { serializeAsJSON } from "../../src/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types"; import { FileId, NonDeletedExcalidrawElement } from "../../src/element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useI18n } from "../../i18n"; import { useI18n } from "../../src/i18n";
import { encryptData, generateEncryptionKey } from "../../data/encryption"; import { encryptData, generateEncryptionKey } from "../../src/data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../src/element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager"; import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../src/constants";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../src/analytics";
import { getFrame } from "../../utils"; import { getFrame } from "../../src/utils";
import { ExcalidrawLogo } from "../../components/ExcalidrawLogo"; import { ExcalidrawLogo } from "../../src/components/ExcalidrawLogo";
export const exportToExcalidrawPlus = async ( export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -1,7 +1,7 @@
import oc from "open-color"; import oc from "open-color";
import React from "react"; import React from "react";
import { THEME } from "../../constants"; import { THEME } from "../../src/constants";
import { Theme } from "../../element/types"; import { Theme } from "../../src/element/types";
// https://github.com/tholman/github-corners // https://github.com/tholman/github-corners
export const GitHubCorner = React.memo( export const GitHubCorner = React.memo(
@@ -1,8 +1,8 @@
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { appLangCodeAtom } from ".."; import { appLangCodeAtom } from "..";
import { useI18n } from "../../i18n"; import { useI18n } from "../../src/i18n";
import { languages } from "../../i18n"; import { languages } from "../../src/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n(); const { t, langCode } = useI18n();
@@ -1,19 +1,19 @@
import { compressData } from "../../data/encode"; import { compressData } from "../../src/data/encode";
import { newElementWith } from "../../element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../src/element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
FileId, FileId,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../element/types"; } from "../../src/element/types";
import { t } from "../../i18n"; import { t } from "../../src/i18n";
import { import {
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFiles, BinaryFiles,
} from "../../types"; } from "../../src/types";
export class FileManager { export class FileManager {
/** files being fetched */ /** files being fetched */
@@ -11,11 +11,11 @@
*/ */
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval"; import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState"; import { clearAppStateForLocalStorage } from "../../src/appState";
import { clearElementsForLocalStorage } from "../../element"; import { clearElementsForLocalStorage } from "../../src/element";
import { ExcalidrawElement, FileId } from "../../element/types"; import { ExcalidrawElement, FileId } from "../../src/element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
import { debounce } from "../../utils"; import { debounce } from "../../src/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
@@ -1,20 +1,20 @@
import { ExcalidrawElement, FileId } from "../../element/types"; import { ExcalidrawElement, FileId } from "../../src/element/types";
import { getSceneVersion } from "../../element"; import { getSceneVersion } from "../../src/element";
import Portal from "../collab/Portal"; import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore"; import { restoreElements } from "../../src/data/restore";
import { import {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,
DataURL, DataURL,
} from "../../types"; } from "../../src/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode"; import { decompressData } from "../../src/data/encode";
import { encryptData, decryptData } from "../../data/encryption"; import { encryptData, decryptData } from "../../src/data/encryption";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../src/constants";
import { reconcileElements } from "../collab/reconciliation"; import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from "."; import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../utility-types"; import { ResolutionType } from "../../src/utility-types";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -1,23 +1,23 @@
import { compressData, decompressData } from "../../data/encode"; import { compressData, decompressData } from "../../src/data/encode";
import { import {
decryptData, decryptData,
generateEncryptionKey, generateEncryptionKey,
IV_LENGTH_BYTES, IV_LENGTH_BYTES,
} from "../../data/encryption"; } from "../../src/data/encryption";
import { serializeAsJSON } from "../../data/json"; import { serializeAsJSON } from "../../src/data/json";
import { restore } from "../../data/restore"; import { restore } from "../../src/data/restore";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../src/data/types";
import { isInvisiblySmallElement } from "../../element/sizeHelpers"; import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../src/element/typeChecks";
import { ExcalidrawElement, FileId } from "../../element/types"; import { ExcalidrawElement, FileId } from "../../src/element/types";
import { t } from "../../i18n"; import { t } from "../../src/i18n";
import { import {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
UserIdleState, UserIdleState,
} from "../../types"; } from "../../src/types";
import { bytesToHexString } from "../../utils"; import { bytesToHexString } from "../../src/utils";
import { import {
DELETED_ELEMENT_TIMEOUT, DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
@@ -1,12 +1,12 @@
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../types"; import { AppState } from "../../src/types";
import { import {
clearAppStateForLocalStorage, clearAppStateForLocalStorage,
getDefaultAppState, getDefaultAppState,
} from "../../appState"; } from "../../src/appState";
import { clearElementsForLocalStorage } from "../../element"; import { clearElementsForLocalStorage } from "../../src/element";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../src/data/types";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {
@@ -1,31 +1,32 @@
import polyfill from "../polyfill"; import polyfill from "../src/polyfill";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../src/analytics";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../src/appState";
import { ErrorDialog } from "../components/ErrorDialog"; import { ErrorDialog } from "../src/components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary"; import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
import { useMathSubtype } from "../src/element/subtypes/mathjax";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT, TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
} from "../constants"; } from "../src/constants";
import { loadFromBlob } from "../data/blob"; import { loadFromBlob } from "../src/data/blob";
import { import {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
Theme, Theme,
} from "../element/types"; } from "../src/element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../src/i18n";
import { import {
Excalidraw, Excalidraw,
defaultLang, defaultLang,
LiveCollaborationTrigger, LiveCollaborationTrigger,
} from "../packages/excalidraw/index"; } from "../src/packages/excalidraw/index";
import { import {
AppState, AppState,
LibraryItems, LibraryItems,
@@ -33,7 +34,7 @@ import {
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState, UIAppState,
} from "../types"; } from "../src/types";
import { import {
debounce, debounce,
getVersion, getVersion,
@@ -43,7 +44,7 @@ import {
ResolvablePromise, ResolvablePromise,
resolvablePromise, resolvablePromise,
isRunningInIframe, isRunningInIframe,
} from "../utils"; } from "../src/utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
STORAGE_KEYS, STORAGE_KEYS,
@@ -68,33 +69,40 @@ import {
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore"; import {
restore,
restoreAppState,
RestoredDataState,
} from "../src/data/restore";
import { import {
ExportToExcalidrawPlus, ExportToExcalidrawPlus,
exportToExcalidrawPlus, exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus"; } from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../src/element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks"; import { isInitializedImageElement } from "../src/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase"; import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData"; import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx"; import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation"; import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "../src/data/library";
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai"; import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../jotai"; import { useAtomWithInitialValue } from "../src/jotai";
import { appJotaiStore } from "./app-jotai"; import { appJotaiStore } from "./app-jotai";
import "./index.scss"; import "./index.scss";
import { ResolutionType } from "../utility-types"; import { ResolutionType } from "../src/utility-types";
import { ShareableLinkDialog } from "../components/ShareableLinkDialog"; import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../components/Trans"; import Trans from "../src/components/Trans";
polyfill(); polyfill();
@@ -296,6 +304,8 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] = const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>(); useCallbackRefState<ExcalidrawImperativeAPI>();
useMathSubtype(excalidrawAPI);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom); const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { 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 { import {
mockBoundingClientRect, mockBoundingClientRect,
render, render,
restoreOriginalGetBoundingClientRect, restoreOriginalGetBoundingClientRect,
} from "./test-utils"; } from "../../src/tests/test-utils";
import { UI } from "./helpers/ui"; import { UI } from "../../src/tests/helpers/ui";
describe("Test MobileMenu", () => { describe("Test MobileMenu", () => {
const { h } = window; const { h } = window;
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
import { vi } from "vitest"; import { vi } from "vitest";
import { render, updateSceneData, waitFor } from "./test-utils"; import { render, updateSceneData, waitFor } from "../../src/tests/test-utils";
import ExcalidrawApp from "../excalidraw-app"; import ExcalidrawApp from "../../excalidraw-app";
import { API } from "./helpers/api"; import { API } from "../../src/tests/helpers/api";
import { createUndoAction } from "../actions/actionHistory"; import { createUndoAction } from "../../src/actions/actionHistory";
const { h } = window; const { h } = window;
Object.defineProperty(window, "crypto", { 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; const module = (await importActual()) as any;
return { return {
__esmodule: true, __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 loadFromFirebase = async () => null;
const saveToFirebase = () => {}; const saveToFirebase = () => {};
const isSavedToFirebase = () => true; const isSavedToFirebase = () => true;
@@ -1,13 +1,13 @@
import { expect } from "chai"; import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../constants"; import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { import {
BroadcastedExcalidrawElement, BroadcastedExcalidrawElement,
ReconciledElements, ReconciledElements,
reconcileElements, reconcileElements,
} from "../excalidraw-app/collab/reconciliation"; } from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../random"; import { randomInteger } from "../../src/random";
import { AppState } from "../types"; import { AppState } from "../../src/types";
type Id = string; type Id = string;
type ElementLike = { type ElementLike = {
+6
View File
@@ -31,6 +31,7 @@
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"copyfiles": "2.4.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
@@ -40,18 +41,22 @@
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "1.13.1", "jotai": "1.13.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"mathjax-full": "https://github.com/MathJax/MathJax-src#develop",
"nanoid": "3.3.3", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"patch-package": "8.0.0",
"perfect-freehand": "1.2.0", "perfect-freehand": "1.2.0",
"pica": "7.1.1", "pica": "7.1.1",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0", "png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0", "points-on-curve": "0.2.0",
"postinstall-postinstall": "2.1.0",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "7.0.1",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
@@ -111,6 +116,7 @@
"fix": "yarn fix:other && yarn fix:code", "fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.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", "prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "vite", "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, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText, measureTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@@ -31,7 +31,6 @@ import {
} from "../element/types"; } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
export const actionUnbindText = register({ export const actionUnbindText = register({
@@ -48,10 +47,11 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const { width, height, baseline } = measureText( const { width, height, baseline } = measureTextElement(
boundTextElement.originalText, boundTextElement,
getFontString(boundTextElement), {
boundTextElement.lineHeight, text: boundTextElement.originalText,
},
); );
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
+62 -11
View File
@@ -2,10 +2,10 @@ import React from "react";
import { import {
Action, Action,
UpdaterFn, UpdaterFn,
ActionName,
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource, ActionSource,
ActionPredicateFn,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
@@ -40,7 +40,8 @@ const trackAction = (
}; };
export class ActionManager { export class ActionManager {
actions = {} as Record<ActionName, Action>; actions = {} as Record<Action["name"], Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -68,6 +69,37 @@ export class ActionManager {
this.app = app; 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) { registerAction(action: Action) {
this.actions[action.name] = action; this.actions[action.name] = action;
} }
@@ -84,7 +116,7 @@ export class ActionManager {
(action) => (action) =>
(action.name in canvasActions (action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions] ? canvasActions[action.name as keyof typeof canvasActions]
: true) && : this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
@@ -135,7 +167,7 @@ export class ActionManager {
/** /**
* @param data additional data sent to the PanelComponent * @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; const canvasActions = this.app.props.UIOptions.canvasActions;
if ( if (
@@ -143,7 +175,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] && "PanelComponent" in this.actions[name] &&
(name in canvasActions (name in canvasActions
? canvasActions[name as keyof typeof canvasActions] ? canvasActions[name as keyof typeof canvasActions]
: true) : this.isActionEnabled(this.actions[name], { noPredicates: true }))
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
@@ -165,6 +197,7 @@ export class ActionManager {
return ( return (
<PanelComponent <PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()} elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
@@ -178,13 +211,31 @@ export class ActionManager {
return null; return null;
}; };
isActionEnabled = (action: Action) => { isActionEnabled = (
const elements = this.getElementsIncludingDeleted(); action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const data = opts?.data;
return ( if (
!action.predicate || !opts?.noPredicates &&
action.predicate(elements, appState, this.app.props, this.app) 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")], toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export type CustomShortcutName = string;
const shortcuts = shortcutMap[name];
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 // if multiple shortcuts available, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
}; };
+11 -1
View File
@@ -32,6 +32,15 @@ type ActionFn = (
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => 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 UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
@@ -135,7 +144,7 @@ export type PanelComponentProps = {
}; };
export interface Action { export interface Action {
name: ActionName; name: string;
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
@@ -157,6 +166,7 @@ export interface Action {
appState: AppState, appState: AppState,
appProps: ExcalidrawProps, appProps: ExcalidrawProps,
app: AppClassProperties, app: AppClassProperties,
data?: Record<string, any>,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
+2
View File
@@ -146,6 +146,8 @@ const APP_STATE_STORAGE_CONF = (<
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
activeSubtypes: { browser: true, export: false, server: false },
customData: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
+21 -1
View File
@@ -11,6 +11,8 @@ import {
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types"; import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
import { AppState } from "./types";
import { selectSubtype } from "./element/subtypes";
export type ChartElements = readonly NonDeletedExcalidrawElement[]; export type ChartElements = readonly NonDeletedExcalidrawElement[];
@@ -23,6 +25,8 @@ export interface Spreadsheet {
title: string | null; title: string | null;
labels: string[] | null; labels: string[] | null;
values: number[]; values: number[];
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
} }
export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
@@ -193,13 +197,17 @@ const chartXLabels = (
groupId: string, groupId: string,
backgroundColor: string, backgroundColor: string,
): ChartElements => { ): ChartElements => {
const custom = selectSubtype(spreadsheet, "text");
return ( return (
spreadsheet.labels?.map((label, index) => { spreadsheet.labels?.map((label, index) => {
return newTextElement({ return newTextElement({
groupIds: [groupId], groupIds: [groupId],
backgroundColor, backgroundColor,
...commonProps, ...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, x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
@@ -207,6 +215,7 @@ const chartXLabels = (
fontSize: 16, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
...custom,
}); });
}) || [] }) || []
); );
@@ -227,6 +236,7 @@ const chartYLabels = (
y: y - BAR_GAP, y: y - BAR_GAP,
text: "0", text: "0",
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
const maxYLabel = newTextElement({ const maxYLabel = newTextElement({
@@ -237,6 +247,7 @@ const chartYLabels = (
y: y - BAR_HEIGHT - minYLabel.height / 2, y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(), text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
return [minYLabel, maxYLabel]; return [minYLabel, maxYLabel];
@@ -264,6 +275,7 @@ const chartLines = (
[0, 0], [0, 0],
[chartWidth, 0], [chartWidth, 0],
], ],
...selectSubtype(spreadsheet, "line"),
}); });
const yLine = newLinearElement({ const yLine = newLinearElement({
@@ -280,6 +292,7 @@ const chartLines = (
[0, 0], [0, 0],
[0, -chartHeight], [0, -chartHeight],
], ],
...selectSubtype(spreadsheet, "line"),
}); });
const maxLine = newLinearElement({ const maxLine = newLinearElement({
@@ -298,6 +311,7 @@ const chartLines = (
[0, 0], [0, 0],
[chartWidth, 0], [chartWidth, 0],
], ],
...selectSubtype(spreadsheet, "line"),
}); });
return [xLine, yLine, maxLine]; return [xLine, yLine, maxLine];
@@ -324,6 +338,7 @@ const chartBaseElements = (
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, roundness: null,
textAlign: "center", textAlign: "center",
...selectSubtype(spreadsheet, "text"),
}) })
: null; : null;
@@ -340,6 +355,7 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
...selectSubtype(spreadsheet, "rectangle"),
}) })
: null; : null;
@@ -372,6 +388,7 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP, y: y - barHeight - BAR_GAP,
width: BAR_WIDTH, width: BAR_WIDTH,
height: barHeight, height: barHeight,
...selectSubtype(spreadsheet, "rectangle"),
}); });
}); });
@@ -424,6 +441,7 @@ const chartTypeLine = (
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
points: points as any, points: points as any,
...selectSubtype(spreadsheet, "line"),
}); });
const dots = spreadsheet.values.map((value, index) => { const dots = spreadsheet.values.map((value, index) => {
@@ -440,6 +458,7 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2, y: y + cy - BAR_GAP * 2,
width: BAR_GAP, width: BAR_GAP,
height: BAR_GAP, height: BAR_GAP,
...selectSubtype(spreadsheet, "ellipse"),
}); });
}); });
@@ -462,6 +481,7 @@ const chartTypeLine = (
[0, 0], [0, 0],
[0, cy], [0, cy],
], ],
...selectSubtype(spreadsheet, "line"),
}); });
}); });
+6 -1
View File
@@ -2,7 +2,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { BinaryFiles } from "./types"; import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
@@ -167,6 +167,7 @@ export const getSystemClipboard = async (
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
isPlainPaste = false, isPlainPaste = false,
appState?: AppState,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event); const systemClipboard = await getSystemClipboard(event);
@@ -186,6 +187,10 @@ export const parseClipboard = async (
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard); !isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) { if (spreadsheetResult) {
if ("spreadsheet" in spreadsheetResult) {
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
spreadsheetResult.spreadsheet.customData = appState?.customData;
}
return spreadsheetResult; return spreadsheetResult;
} }
+3
View File
@@ -23,6 +23,7 @@ import {
} from "../utils"; } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { SubtypeShapeActions, SubtypeToggles } from "./Subtypes";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement } from "../element/typeChecks";
@@ -100,6 +101,7 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && ( {showChangeBackgroundIcons && (
<div>{renderAction("changeBackgroundColor")}</div> <div>{renderAction("changeBackgroundColor")}</div>
)} )}
<SubtypeShapeActions elements={targetElements} />
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(appState.activeTool.type) ||
@@ -400,6 +402,7 @@ export const ShapesSwitcher = ({
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
)} )}
<SubtypeToggles />
</> </>
); );
}; };
+72 -12
View File
@@ -270,6 +270,16 @@ import {
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import {
SubtypeLoadedCb,
SubtypeRecord,
SubtypePrepFn,
checkRefreshOnSubtypeLoad,
isSubtypeAction,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../element/subtypes";
import { import {
dataURLToFile, dataURLToFile,
generateIdFromFile, generateIdFromFile,
@@ -509,6 +519,12 @@ class App extends React.Component<AppProps, AppState> {
this.id = nanoid(); this.id = nanoid();
this.library = new Library(this); this.library = new Library(this);
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.scene = new Scene(); this.scene = new Scene();
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
@@ -535,6 +551,8 @@ class App extends React.Component<AppProps, AppState> {
getSceneElements: this.getSceneElements, getSceneElements: this.getSceneElements,
getAppState: () => this.state, getAppState: () => this.state,
getFiles: () => this.files, getFiles: () => this.files,
actionManager: this.actionManager,
addSubtype: this.addSubtype,
refresh: this.refresh, refresh: this.refresh,
setToast: this.setToast, setToast: this.setToast,
id: this.id, id: this.id,
@@ -562,16 +580,27 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated, onSceneUpdated: this.onSceneUpdated,
}); });
this.history = new History(); this.history = new History();
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(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) { private onWindowMessage(event: MessageEvent) {
@@ -1330,7 +1359,8 @@ class App extends React.Component<AppProps, AppState> {
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => { private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
jotaiStore.set(activeEyeDropperAtom, { jotaiStore.set(activeEyeDropperAtom, {
swapPreviewOnAlt: true, swapPreviewOnAlt: true,
previewType: type === "stroke" ? "strokeColor" : "backgroundColor", colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground",
onSelect: (color, event) => { onSelect: (color, event) => {
const shouldUpdateStrokeColor = const shouldUpdateStrokeColor =
(type === "background" && event.altKey) || (type === "background" && event.altKey) ||
@@ -1341,12 +1371,14 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type !== "selection" this.state.activeTool.type !== "selection"
) { ) {
if (shouldUpdateStrokeColor) { if (shouldUpdateStrokeColor) {
this.setState({ this.syncActionResult({
currentItemStrokeColor: color, appState: { ...this.state, currentItemStrokeColor: color },
commitToHistory: true,
}); });
} else { } else {
this.setState({ this.syncActionResult({
currentItemBackgroundColor: color, appState: { ...this.state, currentItemBackgroundColor: color },
commitToHistory: true,
}); });
} }
} else { } else {
@@ -2156,7 +2188,7 @@ class App extends React.Component<AppProps, AppState> {
// (something something security) // (something something security)
let file = event?.clipboardData?.files[0]; 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) { if (!file && data.text && !isPlainPaste) {
const string = data.text.trim(); const string = data.text.trim();
if (string.startsWith("<svg") && string.endsWith("</svg>")) { if (string.startsWith("<svg") && string.endsWith("</svg>")) {
@@ -2386,6 +2418,7 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: this.state.currentItemFontFamily, fontFamily: this.state.currentItemFontFamily,
textAlign: this.state.currentItemTextAlign, textAlign: this.state.currentItemTextAlign,
verticalAlign: DEFAULT_VERTICAL_ALIGN, verticalAlign: DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
locked: false, locked: false,
}; };
@@ -3522,6 +3555,7 @@ class App extends React.Component<AppProps, AppState> {
verticalAlign: parentCenterPosition verticalAlign: parentCenterPosition
? VERTICAL_ALIGN.MIDDLE ? VERTICAL_ALIGN.MIDDLE
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
lineHeight, lineHeight,
@@ -5375,6 +5409,7 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
roundness: null, roundness: null,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
...selectSubtype(this.state, "image"),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -5475,6 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
: null, : null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -5551,6 +5587,7 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: this.getCurrentItemRoundness(elementType), roundness: this.getCurrentItemRoundness(elementType),
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
} as const; } as const;
@@ -7871,6 +7908,29 @@ class App extends React.Component<AppProps, AppState> {
private getContextMenuItems = ( private getContextMenuItems = (
type: "canvas" | "element", 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 => { ): ContextMenuItems => {
const options: ContextMenuItems = []; const options: ContextMenuItems = [];
+7 -4
View File
@@ -1,7 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker"; import { getColor } from "./ColorPicker";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import {
ColorPickerType,
activeColorPickerSectionAtom,
} from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
@@ -15,14 +18,14 @@ interface ColorInputProps {
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
label: string; label: string;
eyeDropperType: "strokeColor" | "backgroundColor"; colorPickerType: ColorPickerType;
} }
export const ColorInput = ({ export const ColorInput = ({
color, color,
onChange, onChange,
label, label,
eyeDropperType, colorPickerType,
}: ColorInputProps) => { }: ColorInputProps) => {
const device = useDevice(); const device = useDevice();
const [innerValue, setInnerValue] = useState(color); const [innerValue, setInnerValue] = useState(color);
@@ -116,7 +119,7 @@ export const ColorInput = ({
: { : {
keepOpenOnAlt: false, keepOpenOnAlt: false,
onSelect: (color) => onChange(color), onSelect: (color) => onChange(color),
previewType: eyeDropperType, colorPickerType,
}, },
) )
} }
+5 -12
View File
@@ -82,14 +82,7 @@ const ColorPickerPopupContent = ({
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice(); const { isMobile, isLandscape } = useDevice();
const eyeDropperType = const colorInputJSX = (
type === "canvasBackground"
? undefined
: type === "elementBackground"
? "backgroundColor"
: "strokeColor";
const colorInputJSX = eyeDropperType && (
<div> <div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput <ColorInput
@@ -98,7 +91,7 @@ const ColorPickerPopupContent = ({
onChange={(color) => { onChange={(color) => {
onChange(color); onChange(color);
}} }}
eyeDropperType={eyeDropperType} colorPickerType={type}
/> />
</div> </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)", "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 <Picker
palette={palette} palette={palette}
color={color} color={color}
@@ -173,7 +166,7 @@ const ColorPickerPopupContent = ({
state = state || { state = state || {
keepOpenOnAlt: true, keepOpenOnAlt: true,
onSelect: onChange, onSelect: onChange,
previewType: eyeDropperType, colorPickerType: type,
}; };
state.keepOpenOnAlt = true; state.keepOpenOnAlt = true;
return state; return state;
@@ -184,7 +177,7 @@ const ColorPickerPopupContent = ({
: { : {
keepOpenOnAlt: false, keepOpenOnAlt: false,
onSelect: onChange, onSelect: onChange,
previewType: eyeDropperType, colorPickerType: type,
}; };
}); });
}} }}
+50 -36
View File
@@ -1,35 +1,47 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { rgbToHex } from "../colors"; import { rgbToHex } from "../colors";
import { EVENT } from "../constants"; import { EVENT } from "../constants";
import { useUIAppState } from "../context/ui-appState"; import { useUIAppState } from "../context/ui-appState";
import { mutateElement } from "../element/mutateElement";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer"; import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick"; import { useOutsideClick } from "../hooks/useOutsideClick";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import Scene from "../scene/Scene";
import { ShapeCache } from "../scene/ShapeCache";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App"; import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import { useStable } from "../hooks/useStable";
import "./EyeDropper.scss"; import "./EyeDropper.scss";
import { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import { ExcalidrawElement } from "../element/types";
type EyeDropperProperties = { export type EyeDropperProperties = {
keepOpenOnAlt: boolean; keepOpenOnAlt: boolean;
swapPreviewOnAlt?: boolean; swapPreviewOnAlt?: boolean;
/** called when user picks color (on pointerup) */
onSelect: (color: string, event: PointerEvent) => void; 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 activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
export const EyeDropper: React.FC<{ export const EyeDropper: React.FC<{
onCancel: () => void; onCancel: () => void;
onSelect: Required<EyeDropperProperties>["onSelect"]; onSelect: EyeDropperProperties["onSelect"];
swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"]; /** called when color changes, on pointerdown for preview */
previewType: EyeDropperProperties["previewType"]; onChange: (
}> = ({ onCancel, onSelect, swapPreviewOnAlt, previewType }) => { type: ColorPickerType,
color: string,
selectedElements: ExcalidrawElement[],
event: { altKey: boolean },
) => void;
colorPickerType: EyeDropperProperties["colorPickerType"];
}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
const eyeDropperContainer = useCreatePortalContainer({ const eyeDropperContainer = useCreatePortalContainer({
className: "excalidraw-eye-dropper-backdrop", className: "excalidraw-eye-dropper-backdrop",
parentSelector: ".excalidraw-eye-dropper-container", parentSelector: ".excalidraw-eye-dropper-container",
@@ -40,9 +52,13 @@ export const EyeDropper: React.FC<{
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const metaStuffRef = useRef({ selectedElements, app }); const stableProps = useStable({
metaStuffRef.current.selectedElements = selectedElements; app,
metaStuffRef.current.app = app; onCancel,
onChange,
onSelect,
selectedElements,
});
const { container: excalidrawContainer } = useExcalidrawContainer(); const { container: excalidrawContainer } = useExcalidrawContainer();
@@ -90,28 +106,28 @@ export const EyeDropper: React.FC<{
const currentColor = getCurrentColor({ clientX, clientY }); const currentColor = getCurrentColor({ clientX, clientY });
if (isHoldingPointerDown) { if (isHoldingPointerDown) {
for (const element of metaStuffRef.current.selectedElements) { stableProps.onChange(
mutateElement( colorPickerType,
element, currentColor,
{ stableProps.selectedElements,
[altKey && swapPreviewOnAlt { altKey },
? previewType === "strokeColor" );
? "backgroundColor"
: "strokeColor"
: previewType]: currentColor,
},
false,
);
ShapeCache.delete(element);
}
Scene.getScene(
metaStuffRef.current.selectedElements[0],
)?.informMutation();
} }
colorPreviewDiv.style.background = currentColor; colorPreviewDiv.style.background = currentColor;
}; };
const onCancel = () => {
stableProps.onCancel();
};
const onSelect: Required<EyeDropperProperties>["onSelect"] = (
color,
event,
) => {
stableProps.onSelect(color, event);
};
const pointerDownListener = (event: PointerEvent) => { const pointerDownListener = (event: PointerEvent) => {
isHoldingPointerDown = true; isHoldingPointerDown = true;
// NOTE we can't event.preventDefault() as that would stop // 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 // init color preview else it would show only after the first mouse move
mouseMoveListener({ mouseMoveListener({
clientX: metaStuffRef.current.app.lastViewportPosition.x, clientX: stableProps.app.lastViewportPosition.x,
clientY: metaStuffRef.current.app.lastViewportPosition.y, clientY: stableProps.app.lastViewportPosition.y,
altKey: false, altKey: false,
}); });
@@ -179,12 +195,10 @@ export const EyeDropper: React.FC<{
window.removeEventListener(EVENT.BLUR, onCancel); window.removeEventListener(EVENT.BLUR, onCancel);
}; };
}, [ }, [
stableProps,
app.canvas, app.canvas,
eyeDropperContainer, eyeDropperContainer,
onCancel, colorPickerType,
onSelect,
swapPreviewOnAlt,
previewType,
excalidrawContainer, excalidrawContainer,
appState.offsetLeft, appState.offsetLeft,
appState.offsetTop, appState.offsetTop,
+38 -2
View File
@@ -52,6 +52,9 @@ import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -368,11 +371,44 @@ const LayerUI = ({
)} )}
{eyeDropperState && !device.isMobile && ( {eyeDropperState && !device.isMobile && (
<EyeDropper <EyeDropper
swapPreviewOnAlt={eyeDropperState.swapPreviewOnAlt} colorPickerType={eyeDropperState.colorPickerType}
previewType={eyeDropperState.previewType}
onCancel={() => { onCancel={() => {
setEyeDropperState(null); 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) => { onSelect={(color, event) => {
setEyeDropperState((state) => { setEyeDropperState((state) => {
return state?.keepOpenOnAlt && event.altKey ? state : null; return state?.keepOpenOnAlt && event.altKey ? state : null;
+49 -30
View File
@@ -10,6 +10,12 @@ import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; 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; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -25,41 +31,54 @@ const ChartPreviewBtn = (props: {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
if (!props.spreadsheet) {
return;
}
const elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
setChartElements(elements);
let svg: SVGSVGElement; let svg: SVGSVGElement;
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg( (async () => {
elements, let elements: ChartElements;
{ await ensureSubtypesLoaded(
exportBackground: false, props.spreadsheet?.activeSubtypes ?? [],
viewBackgroundColor: oc.white, () => {
}, if (!props.spreadsheet) {
null, // files return;
); }
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) { elements = renderSpreadsheet(
(previewNode.parentNode as HTMLDivElement).focus(); 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]); }, [props.spreadsheet, props.chartType, props.selected]);
return ( 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 "../element/types";
import { THEME } from "../constants"; 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) => const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e"; theme === THEME.LIGHT ? oc.white : "#1e1e1e";
+15 -8
View File
@@ -34,13 +34,14 @@ import {
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isValidSubtype } from "../element/subtypes";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import { import {
detectLineHeight, detectLineHeight,
getDefaultLineHeight, getDefaultLineHeight,
measureBaseline, measureTextElement,
} from "../element/textElement"; } from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
@@ -92,7 +93,8 @@ const repairBinding = (binding: PointBinding | null) => {
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & { T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -158,6 +160,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("subtype" in element && isValidSubtype(element.subtype, base.type)) {
base.subtype = element.subtype;
}
if ("customData" in element) { if ("customData" in element) {
base.customData = element.customData; base.customData = element.customData;
} }
@@ -203,11 +208,7 @@ const restoreElement = (
: // no element height likely means programmatic use, so default : // no element height likely means programmatic use, so default
// to a fixed line height // to a fixed line height
getDefaultLineHeight(element.fontFamily)); getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline( const baseline = measureTextElement(element, { text }).baseline;
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
@@ -528,6 +529,12 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
if ("activeSubtypes" in appState) {
nextAppState.activeSubtypes = appState.activeSubtypes;
}
if ("customData" in appState) {
nextAppState.customData = appState.customData;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+26 -3
View File
@@ -6,12 +6,23 @@ import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { maybeGetSubtypeProps } from "./newElement";
import { getSubtypeMethods } from "./subtypes";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit< type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" "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. // 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 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 // 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, informMutation = true,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -70,6 +83,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
} }
if (!didChangePoints) { if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue; continue;
} }
} }
@@ -77,6 +91,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value; (element as any)[key] = value;
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
if (!didChange) { if (!didChange) {
@@ -92,9 +107,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; if (increment) {
element.versionNonce = randomInteger(); element.version++;
element.updated = getUpdatedTimestamp(); element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) { if (informMutation) {
Scene.getScene(element)?.informMutation(); Scene.getScene(element)?.informMutation();
@@ -108,6 +125,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) { for (const key in updates) {
const value = (updates as any)[key]; const value = (updates as any)[key];
if (typeof value !== "undefined") { if (typeof value !== "undefined") {
@@ -119,6 +138,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue; continue;
} }
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -126,6 +146,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
if (!increment) {
return { ...element, ...updates };
}
return { return {
...element, ...element,
...updates, ...updates,
-1
View File
@@ -203,7 +203,6 @@ describe("duplicating multiple elements", () => {
); );
clonedArrows.forEach((arrow) => { clonedArrows.forEach((arrow) => {
// console.log(arrow);
expect( expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id), clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual( ).toEqual(
+54 -24
View File
@@ -15,12 +15,7 @@ import {
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
} from "../element/types"; } from "../element/types";
import { import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement"; import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
@@ -30,9 +25,9 @@ import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
getContainerElement, getContainerElement,
measureText, measureTextElement,
normalizeText, normalizeText,
wrapText, wrapTextElement,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getDefaultLineHeight, getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
@@ -45,6 +40,30 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { MarkOptional, Merge, Mutable } from "../utility-types"; 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< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -58,6 +77,8 @@ export type ElementConstructorOpts = MarkOptional<
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link" | "link"
| "subtype"
| "customData"
| "strokeStyle" | "strokeStyle"
| "fillStyle" | "fillStyle"
| "strokeColor" | "strokeColor"
@@ -93,8 +114,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }, type),
id: rest.id || randomId(), id: rest.id || randomId(),
type, type,
x, x,
@@ -128,8 +151,11 @@ export const newElement = (
opts: { opts: {
type: ExcalidrawGenericElement["type"]; type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> => {
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
@@ -196,10 +222,12 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily); const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText( const metrics = measureTextElement(
text, { ...opts, fontSize, fontFamily, lineHeight },
getFontString({ fontFamily, fontSize }), {
lineHeight, text,
customData: opts.customData,
},
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -244,7 +272,9 @@ const getAdjustedDimensions = (
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseline, baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight); } = measureTextElement(element, {
text: nextText,
});
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
@@ -253,11 +283,7 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId !element.containerId
) { ) {
const prevMetrics = measureText( const prevMetrics = measureTextElement(element);
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,
@@ -313,11 +339,9 @@ export const refreshTextDimensions = (
} }
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (container) { if (container) {
text = wrapText( text = wrapTextElement(textElement, getBoundTextMaxWidth(container), {
text, text,
getFontString(textElement), });
getBoundTextMaxWidth(container),
);
} }
const dimensions = getAdjustedDimensions(textElement, text); const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions }; return { text, ...dimensions };
@@ -349,6 +373,8 @@ export const newFreeDrawElement = (
simulatePressure: boolean; simulatePressure: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -366,6 +392,8 @@ export const newLinearElement = (
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -385,6 +413,8 @@ export const newImageElement = (
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => { ): NonDeleted<ExcalidrawImageElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawImageElement>("image", opts), ..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements, // in the future we'll support changing stroke color for some SVG elements,
+2 -6
View File
@@ -51,7 +51,7 @@ import {
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
measureText, measureTextElement,
getBoundTextMaxHeight, getBoundTextMaxHeight,
} from "./textElement"; } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
@@ -223,11 +223,7 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) { if (nextFontSize < MIN_FONT_SIZE) {
return null; return null;
} }
const metrics = measureText( const metrics = measureTextElement(element, { fontSize: nextFontSize });
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
return { return {
size: nextFontSize, size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height), 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 { getFontString, arrayToMap, isTestEnv } from "../utils";
import { import {
ExcalidrawElement, ExcalidrawElement,
@@ -36,6 +37,30 @@ import {
} from "./textWysiwyg"; } from "./textWysiwyg";
import { ExtractSetType } from "../utility-types"; 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) => { export const normalizeText = (text: string) => {
return ( return (
text text
@@ -68,22 +93,24 @@ export const redrawTextBoundingBox = (
if (container) { if (container) {
maxWidth = getBoundTextMaxWidth(container, textElement); maxWidth = getBoundTextMaxWidth(container, textElement);
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
textElement.originalText,
getFontString(textElement),
maxWidth,
);
} }
const metrics = measureText( const metrics = measureTextElement(textElement, {
boundTextUpdates.text, text: boundTextUpdates.text,
getFontString(textElement), });
textElement.lineHeight,
);
boundTextUpdates.width = metrics.width; boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height; boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline; 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) { if (container) {
const maxContainerHeight = getBoundTextMaxHeight( const maxContainerHeight = getBoundTextMaxHeight(
container, container,
@@ -196,17 +223,9 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s") (transformHandleType !== "n" && transformHandleType !== "s")
) { ) {
if (text) { if (text) {
text = wrapText( text = wrapTextElement(textElement, maxWidth);
textElement.originalText,
getFontString(textElement),
maxWidth,
);
} }
const metrics = measureText( const metrics = measureTextElement(textElement, { text });
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = metrics.height; nextHeight = metrics.height;
nextWidth = metrics.width; nextWidth = metrics.width;
nextBaseLine = metrics.baseline; nextBaseLine = metrics.baseline;
+5 -5
View File
@@ -1,5 +1,5 @@
import ReactDOM from "react-dom"; 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 { GlobalTestState, render, screen } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
@@ -41,7 +41,7 @@ describe("textWysiwyg", () => {
describe("start text editing", () => { describe("start text editing", () => {
const { h } = window; const { h } = window;
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; h.elements = [];
}); });
@@ -243,7 +243,7 @@ describe("textWysiwyg", () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
//@ts-ignore //@ts-ignore
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!); h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
@@ -477,7 +477,7 @@ describe("textWysiwyg", () => {
const { h } = window; const { h } = window;
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; h.elements = [];
rectangle = UI.createElement("rectangle", { rectangle = UI.createElement("rectangle", {
@@ -1511,7 +1511,7 @@ describe("textWysiwyg", () => {
}); });
it("should bump the version of labelled arrow when label updated", async () => { 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", { const arrow = UI.createElement("arrow", {
width: 300, width: 300,
height: 0, height: 0,
+57 -7
View File
@@ -26,6 +26,7 @@ import {
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
measureText,
normalizeText, normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
wrapText, wrapText,
@@ -43,8 +44,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App"; import App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
import { SubtypeMethods, getSubtypeMethods } from "./subtypes";
const getTransform = ( const getTransform = (
offsetX: number,
width: number, width: number,
height: number, height: number,
angle: number, angle: number,
@@ -62,7 +65,8 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) { if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2; 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: { const originalContainerCache: {
@@ -97,6 +101,14 @@ export const getOriginalContainerHeightFromCache = (
return originalContainerCache[id]?.height ?? null; 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 = ({ export const textWysiwyg = ({
id, id,
onChange, onChange,
@@ -156,11 +168,24 @@ export const textWysiwyg = ({
const container = getContainerElement(updatedTextElement); const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height; // Editing metrics
let textElementWidth = updatedTextElement.width; 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 // Set to element height by default since that's
// what is going to be used for unbounded text // 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 (container && updatedTextElement.containerId) {
if (isArrowElement(container)) { if (isArrowElement(container)) {
@@ -246,13 +271,35 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff; editable.selectionEnd = editable.value.length - diff;
} }
let transformWidth = updatedTextElement.width;
if (!container) { if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth); textElementWidth = Math.min(textElementWidth, maxWidth);
} else { } else {
textElementWidth += 0.5; 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; let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size // 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), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight, lineHeight,
width: `${textElementWidth}px`, width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
...transformOrigin,
transform: getTransform( transform: getTransform(
textElementWidth, offsetX,
textElementHeight, transformWidth,
updatedTextElement.height,
getTextElementAngle(updatedTextElement), getTextElementAngle(updatedTextElement),
appState, appState,
maxWidth, maxWidth,
@@ -334,6 +383,7 @@ export const textWysiwyg = ({
whiteSpace, whiteSpace,
overflowWrap: "break-word", overflowWrap: "break-word",
boxSizing: "content-box", boxSizing: "content-box",
...getEditorStyle(element),
}); });
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
+1
View File
@@ -65,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number; updated: number;
link: string | null; link: string | null;
locked: boolean; locked: boolean;
subtype?: string;
customData?: Record<string, any>; customData?: Record<string, any>;
}>; }>;
+2
View File
@@ -116,3 +116,5 @@ declare namespace jest {
toBeNonNaNNumber(): void; 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 currentLang: Language = defaultLang;
let currentLangData = {}; 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) => { export const setLanguage = async (lang: Language) => {
currentLang = lang; currentLang = lang;
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr"; document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
@@ -99,6 +115,17 @@ export const setLanguage = async (lang: Language) => {
currentLangData = await import( currentLangData = await import(
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json` /* 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) { } catch (error: any) {
console.error(`Failed to load language ${lang.code}:`, error.message); console.error(`Failed to load language ${lang.code}:`, error.message);
currentLangData = fallbackLangData; currentLangData = fallbackLangData;
@@ -125,7 +152,9 @@ const findPartsForData = (data: any, parts: string[]) => {
}; };
export const t = ( export const t = (
path: NestedKeyOf<typeof fallbackLangData>, path:
| NestedKeyOf<typeof fallbackLangData>
| `${NestedKeyOf<typeof fallbackLangData>}.${string}`,
replacement?: { [key: string]: string | number } | null, replacement?: { [key: string]: string | number } | null,
fallback?: string, fallback?: string,
) => { ) => {
@@ -141,6 +170,13 @@ export const t = (
findPartsForData(currentLangData, parts) || findPartsForData(currentLangData, parts) ||
findPartsForData(fallbackLangData, parts) || findPartsForData(fallbackLangData, parts) ||
fallback; 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) { if (translation === undefined) {
const errorMessage = `Can't find translation for ${path}`; const errorMessage = `Can't find translation for ${path}`;
// in production, don't blow up the app on a missing translation key // 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 { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./excalidraw-app"; import ExcalidrawApp from "../excalidraw-app";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
import "./excalidraw-app/sentry"; import "../excalidraw-app/sentry";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA; window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!; const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement); 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) ## 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) - 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) - 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) - 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, InteractiveCanvasAppState,
} from "../types"; } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { getSubtypeMethods } from "../element/subtypes";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
FRAME_STYLE, FRAME_STYLE,
@@ -264,6 +265,12 @@ const drawElementOnCanvas = (
) => { ) => {
context.globalAlpha = context.globalAlpha =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; ((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) { switch (element.type) {
case "rectangle": case "rectangle":
case "embeddable": case "embeddable":
@@ -897,6 +904,11 @@ export const renderElementToSvg = (
root = anchorTag; root = anchorTag;
} }
const map = getSubtypeMethods(element.subtype);
if (map?.renderSvg) {
map.renderSvg(svgRoot, root, element, { offsetX, offsetY });
return;
}
const opacity = const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
@@ -3,7 +3,7 @@ import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random"; import { reseed } from "../random";
import { render, queryByTestId } from "../tests/test-utils"; import { render, queryByTestId } from "../tests/test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { vi } from "vitest"; import { vi } from "vitest";
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
@@ -35,7 +35,7 @@ describe("Test <App/>", () => {
}; };
}; };
await render(<ExcalidrawApp />); await render(<Excalidraw />);
expect( expect(
queryByTestId( queryByTestId(
document.querySelector(".excalidraw-modal-container")!, 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 > 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`] = ` exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] appState 1`] = `
{ {
"activeEmbeddable": null, "activeEmbeddable": null,
@@ -1,4 +1,4 @@
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { CODES } from "../keys"; import { CODES } from "../keys";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
@@ -9,7 +9,7 @@ import {
screen, screen,
togglePopover, togglePopover,
} from "../tests/test-utils"; } from "../tests/test-utils";
import { copiedStyles } from "./actionStyles"; import { copiedStyles } from "../actions/actionStyles";
const { h } = window; const { h } = window;
@@ -17,7 +17,7 @@ const mouse = new Pointer("mouse");
describe("actionStyles", () => { describe("actionStyles", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
afterEach(async () => { afterEach(async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { render } from "./test-utils"; import { render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../../src/packages/excalidraw/index";
import { defaultLang, setLanguage } from "../i18n"; import { defaultLang, setLanguage } from "../i18n";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
@@ -60,7 +60,7 @@ describe("aligning", () => {
mouse.reset(); mouse.reset();
await setLanguage(defaultLang); await setLanguage(defaultLang);
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
it("aligns two objects correctly to the top", () => { it("aligns two objects correctly to the top", () => {
+21 -16
View File
@@ -1,6 +1,6 @@
import { queryByTestId, render, waitFor } from "./test-utils"; import { queryByTestId, render, waitFor } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
@@ -14,14 +14,17 @@ describe("appState", () => {
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const exportBackground = !defaultAppState.exportBackground; const exportBackground = !defaultAppState.exportBackground;
await render(<ExcalidrawApp />, { await render(
localStorageData: { <Excalidraw
appState: { initialData={{
exportBackground, appState: {
viewBackgroundColor: "#F00", exportBackground,
}, viewBackgroundColor: "#F00",
}, },
}); }}
/>,
{},
);
await waitFor(() => { await waitFor(() => {
expect(h.state.exportBackground).toBe(exportBackground); expect(h.state.exportBackground).toBe(exportBackground);
@@ -53,13 +56,15 @@ describe("appState", () => {
}); });
it("changing fontSize with text tool selected (no element created yet)", async () => { it("changing fontSize with text tool selected (no element created yet)", async () => {
const { container } = await render(<ExcalidrawApp />, { const { container } = await render(
localStorageData: { <Excalidraw
appState: { initialData={{
currentItemFontSize: 30, appState: {
}, currentItemFontSize: 30,
}, },
}); }}
/>,
);
UI.clickTool("text"); UI.clickTool("text");
+2 -2
View File
@@ -1,5 +1,5 @@
import { fireEvent, render } from "./test-utils"; 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 { UI, Pointer, Keyboard } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles"; import { getTransformHandles } from "../element/transformHandles";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
@@ -12,7 +12,7 @@ const mouse = new Pointer("mouse");
describe("element binding", () => { describe("element binding", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
it("should create valid binding if duplicate start/end points", async () => { it("should create valid binding if duplicate start/end points", async () => {
+8 -4
View File
@@ -7,7 +7,7 @@ import {
createPasteEvent, createPasteEvent,
} from "./test-utils"; } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui"; import { Pointer, Keyboard } from "./helpers/ui";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { import {
getDefaultLineHeight, getDefaultLineHeight,
@@ -79,8 +79,13 @@ beforeEach(async () => {
mouse.reset(); mouse.reset();
await render(<ExcalidrawApp />); await render(
h.app.setAppState({ zoom: { value: 1 as NormalizedZoomValue } }); <Excalidraw
autoFocus={true}
handleKeyboardGlobally={true}
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
/>,
);
setClipboardText(""); setClipboardText("");
Object.assign(document, { Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas, elementFromPoint: () => GlobalTestState.canvas,
@@ -91,7 +96,6 @@ describe("general paste behavior", () => {
it("should randomize seed on paste", async () => { it("should randomize seed on paste", async () => {
const rectangle = API.createElement({ type: "rectangle" }); const rectangle = API.createElement({ type: "rectangle" });
const clipboardJSON = (await copyToClipboard([rectangle], null))!; const clipboardJSON = (await copyToClipboard([rectangle], null))!;
pasteWithCtrlCmdV(clipboardJSON); pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => { await waitFor(() => {
+5 -8
View File
@@ -11,7 +11,7 @@ import {
waitFor, waitFor,
togglePopover, togglePopover,
} from "./test-utils"; } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random"; import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
@@ -20,7 +20,6 @@ import { ShortcutName } from "../actions/shortcuts";
import { copiedStyles } from "../actions/actionStyles"; import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils"; import { setDateTimeForTests } from "../utils";
import { LibraryItem } from "../types";
import { vi } from "vitest"; import { vi } from "vitest";
const checkpoint = (name: string) => { const checkpoint = (name: string) => {
@@ -56,7 +55,7 @@ describe("contextMenu element", () => {
reseed(7); reseed(7);
setDateTimeForTests("201933152653"); setDateTimeForTests("201933152653");
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
beforeAll(() => { beforeAll(() => {
@@ -394,11 +393,9 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu!, "Add to library")!); fireEvent.click(queryByText(contextMenu!, "Add to library")!);
await waitFor(() => { await waitFor(async () => {
const library = localStorage.getItem("excalidraw-library"); const libraryItems = await h.app.library.getLatestLibrary();
expect(library).not.toBeNull(); expect(libraryItems[0].elements[0]).toEqual(h.elements[0]);
const addedElement = JSON.parse(library!)[0] as LibraryItem;
expect(addedElement.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 ReactDOM from "react-dom";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { import {
@@ -30,7 +30,7 @@ const { h } = window;
describe("Test dragCreate", () => { describe("Test dragCreate", () => {
describe("add element to the scene when pointer dragging long enough", () => { describe("add element to the scene when pointer dragging long enough", () => {
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("rectangle"); const tool = getByToolName("rectangle");
fireEvent.click(tool); fireEvent.click(tool);
@@ -62,7 +62,7 @@ describe("Test dragCreate", () => {
}); });
it("ellipse", async () => { it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("ellipse"); const tool = getByToolName("ellipse");
fireEvent.click(tool); fireEvent.click(tool);
@@ -95,7 +95,7 @@ describe("Test dragCreate", () => {
}); });
it("diamond", async () => { it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("diamond"); const tool = getByToolName("diamond");
fireEvent.click(tool); fireEvent.click(tool);
@@ -127,7 +127,7 @@ describe("Test dragCreate", () => {
}); });
it("arrow", async () => { it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("arrow"); const tool = getByToolName("arrow");
fireEvent.click(tool); fireEvent.click(tool);
@@ -163,7 +163,7 @@ describe("Test dragCreate", () => {
}); });
it("line", async () => { it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("line"); const tool = getByToolName("line");
fireEvent.click(tool); fireEvent.click(tool);
@@ -207,7 +207,7 @@ describe("Test dragCreate", () => {
}); });
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("rectangle"); const tool = getByToolName("rectangle");
fireEvent.click(tool); fireEvent.click(tool);
@@ -227,7 +227,7 @@ describe("Test dragCreate", () => {
}); });
it("ellipse", async () => { it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("ellipse"); const tool = getByToolName("ellipse");
fireEvent.click(tool); fireEvent.click(tool);
@@ -247,7 +247,7 @@ describe("Test dragCreate", () => {
}); });
it("diamond", async () => { it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("diamond"); const tool = getByToolName("diamond");
fireEvent.click(tool); fireEvent.click(tool);
@@ -267,7 +267,9 @@ describe("Test dragCreate", () => {
}); });
it("arrow", async () => { it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
// select tool // select tool
const tool = getByToolName("arrow"); const tool = getByToolName("arrow");
fireEvent.click(tool); fireEvent.click(tool);
@@ -292,7 +294,9 @@ describe("Test dragCreate", () => {
}); });
it("line", async () => { it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
// select tool // select tool
const tool = getByToolName("line"); const tool = getByToolName("line");
fireEvent.click(tool); fireEvent.click(tool);
+2 -2
View File
@@ -1,5 +1,5 @@
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -15,7 +15,7 @@ const h = window.h;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = []; h.elements = [];
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import { render, waitFor } from "./test-utils"; import { render, waitFor } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { import {
encodePngMetadata, encodePngMetadata,
@@ -42,7 +42,7 @@ Object.defineProperty(window, "TextDecoder", {
describe("export", () => { describe("export", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
}); });
it("export embedded png and reimport", async () => { it("export embedded png and reimport", async () => {
+6 -6
View File
@@ -1,14 +1,14 @@
import { render } from "./test-utils"; import { render } from "./test-utils";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { vi } from "vitest"; import { vi } from "vitest";
const { h } = window; const { h } = window;
describe("fitToContent", () => { describe("fitToContent", () => {
it("should zoom to fit the selected element", async () => { it("should zoom to fit the selected element", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
h.state.width = 10; h.state.width = 10;
h.state.height = 10; h.state.height = 10;
@@ -30,7 +30,7 @@ describe("fitToContent", () => {
}); });
it("should zoom to fit multiple elements", async () => { it("should zoom to fit multiple elements", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
const topLeft = API.createElement({ const topLeft = API.createElement({
width: 20, width: 20,
@@ -61,7 +61,7 @@ describe("fitToContent", () => {
}); });
it("should scroll the viewport to the selected element", async () => { it("should scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
h.state.width = 10; h.state.width = 10;
h.state.height = 10; h.state.height = 10;
@@ -106,7 +106,7 @@ describe("fitToContent animated", () => {
}); });
it("should ease scroll the viewport to the selected element", async () => { it("should ease scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
h.state.width = 10; h.state.width = 10;
h.state.height = 10; h.state.height = 10;
@@ -142,7 +142,7 @@ describe("fitToContent animated", () => {
}); });
it("should animate the scroll but not the zoom", async () => { it("should animate the scroll but not the zoom", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
h.state.width = 50; h.state.width = 50;
h.state.height = 50; h.state.height = 50;
+2 -2
View File
@@ -19,7 +19,7 @@ import {
FileId, FileId,
} from "../element/types"; } from "../element/types";
import { newLinearElement } from "../element"; import { newLinearElement } from "../element";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { NormalizedZoomValue } from "../types"; import { NormalizedZoomValue } from "../types";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
@@ -52,7 +52,7 @@ beforeEach(async () => {
Object.assign(document, { Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas, elementFromPoint: () => GlobalTestState.canvas,
}); });
await render(<ExcalidrawApp />); await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
h.setState({ h.setState({
zoom: { zoom: {
value: 1 as NormalizedZoomValue, value: 1 as NormalizedZoomValue,
+41
View File
@@ -16,6 +16,16 @@ import util from "util";
import path from "path"; import path from "path";
import { getMimeType } from "../../data/blob"; import { getMimeType } from "../../data/blob";
import { import {
SubtypeLoadedCb,
SubtypePrepFn,
SubtypeRecord,
checkRefreshOnSubtypeLoad,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../../element/subtypes";
import {
maybeGetSubtypeProps,
newEmbeddableElement, newEmbeddableElement,
newFrameElement, newFrameElement,
newFreeDrawElement, newFreeDrawElement,
@@ -32,6 +42,26 @@ const readFile = util.promisify(fs.readFile);
const { h } = window; const { h } = window;
export class API { 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[]) => { static setSelectedElements = (elements: ExcalidrawElement[]) => {
h.setState({ h.setState({
selectedElementIds: elements.reduce((acc, element) => { selectedElementIds: elements.reduce((acc, element) => {
@@ -112,6 +142,8 @@ export class API {
verticalAlign?: T extends "text" verticalAlign?: T extends "text"
? ExcalidrawTextElement["verticalAlign"] ? ExcalidrawTextElement["verticalAlign"]
: never; : never;
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
boundElements?: ExcalidrawGenericElement["boundElements"]; boundElements?: ExcalidrawGenericElement["boundElements"];
containerId?: T extends "text" containerId?: T extends "text"
? ExcalidrawTextElement["containerId"] ? ExcalidrawTextElement["containerId"]
@@ -140,6 +172,14 @@ export class API {
const appState = h?.state || getDefaultAppState(); 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< const base: Omit<
ExcalidrawGenericElement, ExcalidrawGenericElement,
| "id" | "id"
@@ -155,6 +195,7 @@ export class API {
| "link" | "link"
| "updated" | "updated"
> = { > = {
...custom,
x, x,
y, y,
angle: rest.angle ?? 0, 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 { assertSelectedElements, render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
@@ -13,14 +13,16 @@ const mouse = new Pointer("mouse");
describe("history", () => { describe("history", () => {
it("initializing scene should end up with single history entry", async () => { it("initializing scene should end up with single history entry", async () => {
await render(<ExcalidrawApp />, { await render(
localStorageData: { <Excalidraw
elements: [API.createElement({ type: "rectangle", id: "A" })], initialData={{
appState: { elements: [API.createElement({ type: "rectangle", id: "A" })],
zenModeEnabled: true, appState: {
}, zenModeEnabled: true,
}, },
}); }}
/>,
);
await waitFor(() => expect(h.state.zenModeEnabled).toBe(true)); await waitFor(() => expect(h.state.zenModeEnabled).toBe(true));
await waitFor(() => await waitFor(() =>
@@ -60,14 +62,16 @@ describe("history", () => {
}); });
it("scene import via drag&drop should create new history entry", async () => { it("scene import via drag&drop should create new history entry", async () => {
await render(<ExcalidrawApp />, { await render(
localStorageData: { <Excalidraw
elements: [API.createElement({ type: "rectangle", id: "A" })], initialData={{
appState: { elements: [API.createElement({ type: "rectangle", id: "A" })],
viewBackgroundColor: "#FFF", appState: {
}, viewBackgroundColor: "#FFF",
}, },
}); }}
/>,
);
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF")); await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
await waitFor(() => await waitFor(() =>
@@ -113,7 +117,7 @@ describe("history", () => {
}); });
it("undo/redo works properly with groups", async () => { 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 rect1 = API.createElement({ type: "rectangle", groupIds: ["A"] });
const rect2 = 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 { fireEvent, render, waitFor } from "./test-utils";
import { queryByTestId } from "@testing-library/react"; import { queryByTestId } from "@testing-library/react";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { LibraryItem, LibraryItems } from "../types"; import { LibraryItem, LibraryItems } from "../types";
@@ -42,7 +42,7 @@ vi.mock("../data/filesystem.ts", async (importOriginal) => {
describe("library", () => { describe("library", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
h.app.library.resetLibrary(); h.app.library.resetLibrary();
}); });
@@ -189,7 +189,7 @@ describe("library", () => {
describe("library menu", () => { describe("library menu", () => {
it("should load library from file picker", async () => { 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(); const latestLibrary = await h.app.library.getLatestLibrary();
expect(latestLibrary.length).toBe(0); expect(latestLibrary.length).toBe(0);
+2 -2
View File
@@ -5,7 +5,7 @@ import {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString, FontString,
} from "../element/types"; } from "../element/types";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { centerPoint } from "../math"; import { centerPoint } from "../math";
import { reseed } from "../random"; import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
@@ -43,7 +43,7 @@ describe("Test Linear Elements", () => {
renderInteractiveScene.mockClear(); renderInteractiveScene.mockClear();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
reseed(7); reseed(7);
const comp = await render(<ExcalidrawApp />); const comp = await render(<Excalidraw handleKeyboardGlobally={true} />);
h.state.width = 1000; h.state.width = 1000;
h.state.height = 1000; h.state.height = 1000;
container = comp.container; container = comp.container;
+4 -4
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils"; import { render, fireEvent } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random"; import { reseed } from "../random";
import { bindOrUnbindLinearElement } from "../element/binding"; import { bindOrUnbindLinearElement } from "../element/binding";
@@ -31,7 +31,7 @@ const { h } = window;
describe("move element", () => { describe("move element", () => {
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
@@ -67,7 +67,7 @@ describe("move element", () => {
}); });
it("rectangles with binding arrow", async () => { it("rectangles with binding arrow", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
// create elements // create elements
const rectA = UI.createElement("rectangle", { size: 100 }); const rectA = UI.createElement("rectangle", { size: 100 });
@@ -119,7 +119,7 @@ describe("move element", () => {
describe("duplicate element on move when ALT is clicked", () => { describe("duplicate element on move when ALT is clicked", () => {
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
+12 -11
View File
@@ -5,7 +5,7 @@ import {
mockBoundingClientRect, mockBoundingClientRect,
restoreOriginalGetBoundingClientRect, restoreOriginalGetBoundingClientRect,
} from "./test-utils"; } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ExcalidrawLinearElement } from "../element/types"; import { ExcalidrawLinearElement } from "../element/types";
@@ -29,7 +29,7 @@ const { h } = window;
describe("remove shape in non linear elements", () => { describe("remove shape in non linear elements", () => {
beforeAll(() => { beforeAll(() => {
mockBoundingClientRect(); mockBoundingClientRect({ width: 1000, height: 1000 });
}); });
afterAll(() => { afterAll(() => {
@@ -37,12 +37,13 @@ describe("remove shape in non linear elements", () => {
}); });
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("rectangle"); const tool = getByToolName("rectangle");
fireEvent.click(tool); fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
@@ -52,7 +53,7 @@ describe("remove shape in non linear elements", () => {
}); });
it("ellipse", async () => { it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("ellipse"); const tool = getByToolName("ellipse");
fireEvent.click(tool); fireEvent.click(tool);
@@ -67,7 +68,7 @@ describe("remove shape in non linear elements", () => {
}); });
it("diamond", async () => { it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("diamond"); const tool = getByToolName("diamond");
fireEvent.click(tool); fireEvent.click(tool);
@@ -84,7 +85,7 @@ describe("remove shape in non linear elements", () => {
describe("multi point mode in linear elements", () => { describe("multi point mode in linear elements", () => {
it("arrow", async () => { it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("arrow"); const tool = getByToolName("arrow");
fireEvent.click(tool); fireEvent.click(tool);
@@ -109,8 +110,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(11); expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
@@ -128,7 +129,7 @@ describe("multi point mode in linear elements", () => {
}); });
it("line", async () => { it("line", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("line"); const tool = getByToolName("line");
fireEvent.click(tool); fireEvent.click(tool);
@@ -153,8 +154,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(11); expect(renderStaticScene).toHaveBeenCalledTimes(10);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
+2 -24
View File
@@ -1,7 +1,7 @@
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { reseed } from "../random"; import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { setDateTimeForTests } from "../utils"; import { setDateTimeForTests } from "../utils";
@@ -13,9 +13,7 @@ import {
render, render,
screen, screen,
togglePopover, togglePopover,
waitFor,
} from "./test-utils"; } from "./test-utils";
import { defaultLang } from "../i18n";
import { FONT_FAMILY } from "../constants"; import { FONT_FAMILY } from "../constants";
import { vi } from "vitest"; import { vi } from "vitest";
@@ -56,7 +54,7 @@ beforeEach(async () => {
finger1.reset(); finger1.reset();
finger2.reset(); finger2.reset();
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
h.setState({ height: 768, width: 1024 }); h.setState({ height: 768, width: 1024 });
}); });
@@ -443,26 +441,6 @@ describe("regression tests", () => {
expect(h.state.zoom.value).toBe(1); 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", () => { it("make a group and duplicate it", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(10, 10);
+2 -2
View File
@@ -6,7 +6,7 @@ import { reseed } from "../random";
import { UI, Keyboard } from "./helpers/ui"; import { UI, Keyboard } from "./helpers/ui";
import { resize } from "./utils"; import { resize } from "./utils";
import { ExcalidrawTextElement } from "../element/types"; import { ExcalidrawTextElement } from "../element/types";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { vi } from "vitest"; import { vi } from "vitest";
@@ -126,7 +126,7 @@ describe("resize rectangle ellipses and diamond elements", () => {
describe("Test text element", () => { describe("Test text element", () => {
it("should update font size via keyboard", async () => { it("should update font size via keyboard", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw handleKeyboardGlobally={true} />);
const textElement = API.createElement({ const textElement = API.createElement({
type: "text", type: "text",
+1 -2
View File
@@ -8,7 +8,6 @@ import { Excalidraw } from "../packages/excalidraw/index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui"; import { Keyboard } from "./helpers/ui";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import ExcalidrawApp from "../excalidraw-app";
const { h } = window; const { h } = window;
@@ -56,7 +55,7 @@ describe("appState", () => {
it("moving by page up/down/left/right", async () => { it("moving by page up/down/left/right", async () => {
mockBoundingClientRect(); mockBoundingClientRect();
await render(<ExcalidrawApp />, {}); await render(<Excalidraw handleKeyboardGlobally={true} />, {});
const scrollTest = () => { const scrollTest = () => {
const initialScrollY = h.state.scrollY; const initialScrollY = h.state.scrollY;
+23 -13
View File
@@ -6,7 +6,7 @@ import {
restoreOriginalGetBoundingClientRect, restoreOriginalGetBoundingClientRect,
assertSelectedElements, assertSelectedElements,
} from "./test-utils"; } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import { Excalidraw } from "../packages/excalidraw/index";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { reseed } from "../random"; import { reseed } from "../random";
@@ -34,7 +34,7 @@ const mouse = new Pointer("mouse");
describe("box-selection", () => { describe("box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
}); });
it("should allow adding to selection via box-select when holding shift", async () => { it("should allow adding to selection via box-select when holding shift", async () => {
@@ -102,7 +102,7 @@ describe("box-selection", () => {
describe("inner box-selection", () => { describe("inner box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
}); });
it("selecting elements visually nested inside another", async () => { it("selecting elements visually nested inside another", async () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
@@ -218,7 +218,7 @@ describe("inner box-selection", () => {
describe("selection element", () => { describe("selection element", () => {
it("create selection element on pointer down", async () => { it("create selection element on pointer down", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("selection"); const tool = getByToolName("selection");
fireEvent.click(tool); fireEvent.click(tool);
@@ -239,7 +239,7 @@ describe("selection element", () => {
}); });
it("resize selection element on pointer move", async () => { it("resize selection element on pointer move", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("selection"); const tool = getByToolName("selection");
fireEvent.click(tool); fireEvent.click(tool);
@@ -261,7 +261,7 @@ describe("selection element", () => {
}); });
it("remove selection element on pointer up", async () => { it("remove selection element on pointer up", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<Excalidraw />);
// select tool // select tool
const tool = getByToolName("selection"); const tool = getByToolName("selection");
fireEvent.click(tool); fireEvent.click(tool);
@@ -287,7 +287,9 @@ describe("select single element on the scene", () => {
}); });
it("rectangle", async () => { it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
@@ -317,7 +319,9 @@ describe("select single element on the scene", () => {
}); });
it("diamond", async () => { it("diamond", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
@@ -347,7 +351,9 @@ describe("select single element on the scene", () => {
}); });
it("ellipse", async () => { it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
@@ -377,7 +383,9 @@ describe("select single element on the scene", () => {
}); });
it("arrow", async () => { it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
@@ -419,7 +427,9 @@ describe("select single element on the scene", () => {
}); });
it("arrow escape", async () => { it("arrow escape", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
{ {
// create element // create element
@@ -464,7 +474,7 @@ describe("select single element on the scene", () => {
describe("tool locking & selection", () => { describe("tool locking & selection", () => {
it("should not select newly created element while tool is locked", async () => { it("should not select newly created element while tool is locked", async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
UI.clickTool("lock"); UI.clickTool("lock");
expect(h.state.activeTool.locked).toBe(true); expect(h.state.activeTool.locked).toBe(true);
@@ -480,7 +490,7 @@ describe("tool locking & selection", () => {
describe("selectedElementIds stability", () => { describe("selectedElementIds stability", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<Excalidraw />);
}); });
it("box-selection should be stable when not changing selection", () => { 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