mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-27 04:07:05 +02:00
Compare commits
23 Commits
v0.16.0
...
zsviczian-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
60a3584e86 | ||
![]() |
fa0e653236 | ||
![]() |
16de3d9243 | ||
![]() |
c65d6506f7 | ||
![]() |
a249f332a2 | ||
![]() |
2e61926a6b | ||
![]() |
e921bfb1ae | ||
![]() |
e6f74350ac | ||
![]() |
fa33aa08ab | ||
![]() |
8b838049df | ||
![]() |
1f4f5e11ae | ||
![]() |
12420592ef | ||
![]() |
bfd318e765 | ||
![]() |
6a821f3b76 | ||
![]() |
84fd13e872 | ||
![]() |
7d2b6f3374 | ||
![]() |
ceb637f5ea | ||
![]() |
4c35eba72d | ||
![]() |
4765f5536e | ||
![]() |
556175558a | ||
![]() |
4db73a7f95 | ||
![]() |
f8b3692262 | ||
![]() |
741d5f1a18 |
@@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
|
||||
|
||||
## Excalidraw.com
|
||||
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/excalidraw-app) is part of this repository as well, and the app features:
|
||||
|
||||
- 📡 PWA support (works offline).
|
||||
- 🤼 Real-time collaboration.
|
||||
|
@@ -38,6 +38,7 @@ To render an item, its recommended to use `MainMenu.Item`.
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| --- | --- | :-: | :-: | --- |
|
||||
| `onSelect` | `function` | Yes | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
|
||||
| `selected` | `boolean` | No | `false` | Whether item is active |
|
||||
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
|
||||
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
|
||||
| `shortcut` | `string` | No | - | The shortcut to be shown for the menu item |
|
||||
@@ -70,6 +71,7 @@ function App() {
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| --- | --- | :-: | :-: | --- |
|
||||
| `onSelect` | `function` | No | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
|
||||
| `selected` | `boolean` | No | `false` | Whether item is active |
|
||||
| `href` | `string` | Yes | - | The `href` attribute to be added to the `anchor` element. |
|
||||
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
|
||||
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Customizing Styles
|
||||
|
||||
Excalidraw is using CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
|
||||
Excalidraw uses CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
|
||||
|
||||
Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector:
|
||||
|
||||
|
@@ -34,7 +34,7 @@ function App() {
|
||||
|
||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
||||
|
||||
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
||||
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
||||
|
||||
```jsx showLineNumbers
|
||||
import { useState, useEffect } from "react";
|
||||
|
@@ -15,7 +15,7 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
1. Run `yarn` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork, run:
|
||||
>
|
||||
> ```bash
|
||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||
|
@@ -15,7 +15,7 @@ const FeatureList = [
|
||||
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Want to build your own app powered by Excalidraw by don't know where to
|
||||
Want to build your own app powered by Excalidraw but don't know where to
|
||||
start?
|
||||
</>
|
||||
),
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../utils";
|
||||
import { debounce, getVersion, nFormatter } from "../src/utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "./data/localStorage";
|
||||
import { DEFAULT_VERSION } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { UIAppState } from "../types";
|
||||
import { DEFAULT_VERSION } from "../src/constants";
|
||||
import { t } from "../src/i18n";
|
||||
import { copyTextToSystemClipboard } from "../src/clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../src/element/types";
|
||||
import { UIAppState } from "../src/types";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
@@ -1,23 +1,23 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawImperativeAPI } from "../../src/types";
|
||||
import { ErrorDialog } from "../../src/components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../src/constants";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../element/types";
|
||||
} from "../../src/element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
restoreElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
} from "../../src/packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../src/types";
|
||||
import {
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
} from "../../utils";
|
||||
} from "../../src/utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
@@ -48,25 +48,25 @@ import {
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { t } from "../../i18n";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
import { t } from "../../src/i18n";
|
||||
import { UserIdleState } from "../../src/types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../src/constants";
|
||||
import {
|
||||
encodeFilesForUpload,
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { AbortError } from "../../errors";
|
||||
import { AbortError } from "../../src/errors";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "../../element/typeChecks";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
} from "../../src/element/typeChecks";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import {
|
||||
ReconciledElements,
|
||||
reconcileElements as _reconcileElements,
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../data/encryption";
|
||||
import { decryptData } from "../../src/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
@@ -6,19 +6,19 @@ import {
|
||||
|
||||
import { TCollabClass } from "./Collab";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import {
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { UserIdleState } from "../../src/types";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../constants";
|
||||
import { encryptData } from "../../src/data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
@@ -1,4 +1,4 @@
|
||||
@import "../../css/variables.module";
|
||||
@import "../../src/css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.RoomDialog {
|
@@ -1,13 +1,13 @@
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { KEYS } from "../../keys";
|
||||
import { copyTextToSystemClipboard } from "../../src/clipboard";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import { getFrame } from "../../src/utils";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { KEYS } from "../../src/keys";
|
||||
|
||||
import { Dialog } from "../../components/Dialog";
|
||||
import { Dialog } from "../../src/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
playerPlayIcon,
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../components/icons";
|
||||
import { TextField } from "../../components/TextField";
|
||||
import { FilledButton } from "../../components/FilledButton";
|
||||
} from "../../src/components/icons";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { FilledButton } from "../../src/components/FilledButton";
|
||||
|
||||
import { ReactComponent as CollabImage } from "../../assets/lock.svg";
|
||||
import { ReactComponent as CollabImage } from "../../src/assets/lock.svg";
|
||||
import "./RoomDialog.scss";
|
||||
|
||||
const getShareIcon = () => {
|
@@ -1,7 +1,7 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../constants";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { arrayToMapWithIndex } from "../../utils";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import { arrayToMapWithIndex } from "../../src/utils";
|
||||
|
||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Footer } from "../../packages/excalidraw/index";
|
||||
import { Footer } from "../../src/packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../components/icons";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { PlusPromoIcon } from "../../src/components/icons";
|
||||
import { MainMenu } from "../../src/packages/excalidraw/index";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../components/icons";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { PlusPromoIcon } from "../../src/components/icons";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { WelcomeScreen } from "../../src/packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { POINTER_EVENTS } from "../../constants";
|
||||
import { POINTER_EVENTS } from "../../src/constants";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
@@ -1,6 +1,6 @@
|
||||
import { shield } from "../../components/icons";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { shield } from "../../src/components/icons";
|
||||
import { Tooltip } from "../../src/components/Tooltip";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
|
||||
export const EncryptedIcon = () => {
|
||||
const { t } = useI18n();
|
@@ -1,20 +1,20 @@
|
||||
import React from "react";
|
||||
import { Card } from "../../components/Card";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { serializeAsJSON } from "../../data/json";
|
||||
import { Card } from "../../src/components/Card";
|
||||
import { ToolButton } from "../../src/components/ToolButton";
|
||||
import { serializeAsJSON } from "../../src/data/json";
|
||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
|
||||
import { FileId, NonDeletedExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { encryptData, generateEncryptionKey } from "../../data/encryption";
|
||||
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { encryptData, generateEncryptionKey } from "../../src/data/encryption";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||
import { encodeFilesForUpload } from "../data/FileManager";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
import { ExcalidrawLogo } from "../../components/ExcalidrawLogo";
|
||||
import { MIME_TYPES } from "../../src/constants";
|
||||
import { trackEvent } from "../../src/analytics";
|
||||
import { getFrame } from "../../src/utils";
|
||||
import { ExcalidrawLogo } from "../../src/components/ExcalidrawLogo";
|
||||
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
@@ -1,7 +1,7 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import { THEME } from "../../constants";
|
||||
import { Theme } from "../../element/types";
|
||||
import { THEME } from "../../src/constants";
|
||||
import { Theme } from "../../src/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
@@ -1,8 +1,8 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { appLangCodeAtom } from "..";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { languages } from "../../i18n";
|
||||
import { useI18n } from "../../src/i18n";
|
||||
import { languages } from "../../src/i18n";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const { t, langCode } = useI18n();
|
@@ -1,19 +1,19 @@
|
||||
import { compressData } from "../../data/encode";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||
import { compressData } from "../../src/data/encode";
|
||||
import { newElementWith } from "../../src/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
} from "../../src/element/types";
|
||||
import { t } from "../../src/i18n";
|
||||
import {
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
} from "../../types";
|
||||
} from "../../src/types";
|
||||
|
||||
export class FileManager {
|
||||
/** files being fetched */
|
@@ -11,11 +11,11 @@
|
||||
*/
|
||||
|
||||
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../appState";
|
||||
import { clearElementsForLocalStorage } from "../../element";
|
||||
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
|
||||
import { debounce } from "../../utils";
|
||||
import { clearAppStateForLocalStorage } from "../../src/appState";
|
||||
import { clearElementsForLocalStorage } from "../../src/element";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../src/types";
|
||||
import { debounce } from "../../src/utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
import { FileManager } from "./FileManager";
|
||||
import { Locker } from "./Locker";
|
@@ -1,20 +1,20 @@
|
||||
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { getSceneVersion } from "../../element";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { getSceneVersion } from "../../src/element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../data/restore";
|
||||
import { restoreElements } from "../../src/data/restore";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "../../types";
|
||||
} from "../../src/types";
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
import { decompressData } from "../../data/encode";
|
||||
import { encryptData, decryptData } from "../../data/encryption";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import { decompressData } from "../../src/data/encode";
|
||||
import { encryptData, decryptData } from "../../src/data/encryption";
|
||||
import { MIME_TYPES } from "../../src/constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
import { ResolutionType } from "../../utility-types";
|
||||
import { ResolutionType } from "../../src/utility-types";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
@@ -1,23 +1,23 @@
|
||||
import { compressData, decompressData } from "../../data/encode";
|
||||
import { compressData, decompressData } from "../../src/data/encode";
|
||||
import {
|
||||
decryptData,
|
||||
generateEncryptionKey,
|
||||
IV_LENGTH_BYTES,
|
||||
} from "../../data/encryption";
|
||||
import { serializeAsJSON } from "../../data/json";
|
||||
import { restore } from "../../data/restore";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { isInvisiblySmallElement } from "../../element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
} from "../../src/data/encryption";
|
||||
import { serializeAsJSON } from "../../src/data/json";
|
||||
import { restore } from "../../src/data/restore";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
import { isInvisiblySmallElement } from "../../src/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "../../src/element/typeChecks";
|
||||
import { ExcalidrawElement, FileId } from "../../src/element/types";
|
||||
import { t } from "../../src/i18n";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
UserIdleState,
|
||||
} from "../../types";
|
||||
import { bytesToHexString } from "../../utils";
|
||||
} from "../../src/types";
|
||||
import { bytesToHexString } from "../../src/utils";
|
||||
import {
|
||||
DELETED_ELEMENT_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
@@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
|
||||
type: "MOUSE_LOCATION";
|
||||
payload: {
|
||||
socketId: string;
|
||||
pointer: { x: number; y: number };
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
@@ -1,12 +1,12 @@
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import { AppState } from "../../src/types";
|
||||
import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "../../appState";
|
||||
import { clearElementsForLocalStorage } from "../../element";
|
||||
} from "../../src/appState";
|
||||
import { clearElementsForLocalStorage } from "../../src/element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ImportedDataState } from "../../src/data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
@@ -1,31 +1,31 @@
|
||||
import polyfill from "../polyfill";
|
||||
import polyfill from "../src/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ErrorDialog } from "../components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import { trackEvent } from "../src/analytics";
|
||||
import { getDefaultAppState } from "../src/appState";
|
||||
import { ErrorDialog } from "../src/components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data/blob";
|
||||
} from "../src/constants";
|
||||
import { loadFromBlob } from "../src/data/blob";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "../element/types";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
} from "../src/element/types";
|
||||
import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
|
||||
import { t } from "../src/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
LiveCollaborationTrigger,
|
||||
} from "../packages/excalidraw/index";
|
||||
} from "../src/packages/excalidraw/index";
|
||||
import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
} from "../src/types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../utils";
|
||||
} from "../src/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
STORAGE_KEYS,
|
||||
@@ -68,33 +68,40 @@ import {
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||
import {
|
||||
restore,
|
||||
restoreAppState,
|
||||
RestoredDataState,
|
||||
} from "../src/data/restore";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
} from "./components/ExportToExcalidrawPlus";
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isInitializedImageElement } from "../element/typeChecks";
|
||||
import { newElementWith } from "../src/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../src/element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
useHandleLibrary,
|
||||
} from "../src/data/library";
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../jotai";
|
||||
import { useAtomWithInitialValue } from "../src/jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../utility-types";
|
||||
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../components/Trans";
|
||||
import { ResolutionType } from "../src/utility-types";
|
||||
import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../src/components/Trans";
|
||||
|
||||
polyfill();
|
||||
|
29
excalidraw-app/tests/LanguageList.test.tsx
Normal file
29
excalidraw-app/tests/LanguageList.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defaultLang } from "../../src/i18n";
|
||||
import { UI } from "../../src/tests/helpers/ui";
|
||||
import { screen, fireEvent, waitFor, render } from "../../src/tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
|
||||
describe("Test LanguageList", () => {
|
||||
it("rerenders UI on language change", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
// select rectangle tool to show properties menu
|
||||
UI.clickTool("rectangle");
|
||||
// english lang should display `thin` label
|
||||
expect(screen.queryByTitle(/thin/i)).not.toBeNull();
|
||||
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
|
||||
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: "de-DE" },
|
||||
});
|
||||
// switching to german, `thin` label should no longer exist
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
|
||||
// reset language
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: defaultLang.code },
|
||||
});
|
||||
// switching back to English
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
|
||||
});
|
||||
});
|
@@ -1,11 +1,11 @@
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
import {
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "./test-utils";
|
||||
} from "../../src/tests/test-utils";
|
||||
|
||||
import { UI } from "./helpers/ui";
|
||||
import { UI } from "../../src/tests/helpers/ui";
|
||||
|
||||
describe("Test MobileMenu", () => {
|
||||
const { h } = window;
|
257
excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
Normal file
257
excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
Normal file
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
import { vi } from "vitest";
|
||||
import { render, updateSceneData, waitFor } from "./test-utils";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { API } from "./helpers/api";
|
||||
import { createUndoAction } from "../actions/actionHistory";
|
||||
import { render, updateSceneData, waitFor } from "../../src/tests/test-utils";
|
||||
import ExcalidrawApp from "../../excalidraw-app";
|
||||
import { API } from "../../src/tests/helpers/api";
|
||||
import { createUndoAction } from "../../src/actions/actionHistory";
|
||||
const { h } = window;
|
||||
|
||||
Object.defineProperty(window, "crypto", {
|
||||
@@ -16,7 +16,7 @@ Object.defineProperty(window, "crypto", {
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("../excalidraw-app/data/index.ts", async (importActual) => {
|
||||
vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => {
|
||||
const module = (await importActual()) as any;
|
||||
return {
|
||||
__esmodule: true,
|
||||
@@ -27,7 +27,7 @@ vi.mock("../excalidraw-app/data/index.ts", async (importActual) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../excalidraw-app/data/firebase.ts", () => {
|
||||
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
||||
const loadFromFirebase = async () => null;
|
||||
const saveToFirebase = () => {};
|
||||
const isSavedToFirebase = () => true;
|
@@ -1,13 +1,13 @@
|
||||
import { expect } from "chai";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../constants";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
|
||||
import { ExcalidrawElement } from "../../src/element/types";
|
||||
import {
|
||||
BroadcastedExcalidrawElement,
|
||||
ReconciledElements,
|
||||
reconcileElements,
|
||||
} from "../excalidraw-app/collab/reconciliation";
|
||||
import { randomInteger } from "../random";
|
||||
import { AppState } from "../types";
|
||||
} from "../../excalidraw-app/collab/reconciliation";
|
||||
import { randomInteger } from "../../src/random";
|
||||
import { AppState } from "../../src/types";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.2.0",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
|
@@ -11,7 +11,7 @@
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
"sizes": "180x180"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
|
@@ -90,7 +90,9 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
newElements = newElements.slice(0, -1);
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
}
|
||||
|
||||
// If the multi point line closes the loop,
|
||||
|
@@ -15,6 +15,7 @@ export const actionToggleGridMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
28
src/actions/actionToggleObjectsSnapMode.tsx
Normal file
28
src/actions/actionToggleObjectsSnapMode.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.objectsSnapMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||
});
|
@@ -80,6 +80,7 @@ export {
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
|
@@ -28,6 +28,7 @@ export type ShortcutName =
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "addToLibrary"
|
||||
| "viewMode"
|
||||
@@ -74,6 +75,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
objectsSnapMode: [getShortcutKey("Alt+S")],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
|
@@ -51,6 +51,7 @@ export type ActionName =
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
|
@@ -99,6 +99,12 @@ export const getDefaultAppState = (): Omit<
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
snapLines: [],
|
||||
originSnapOffset: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
objectsSnapModeEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -206,6 +212,9 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
snapLines: { browser: false, export: false, server: false },
|
||||
originSnapOffset: { browser: false, export: false, server: false },
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@@ -14,13 +14,8 @@ import {
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { UIAppState, Zoom } from "../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
isTransparent,
|
||||
updateActiveTool,
|
||||
setCursorForShape,
|
||||
} from "../utils";
|
||||
import { AppClassProperties, UIAppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
@@ -36,7 +31,12 @@ import {
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
frameToolIcon,
|
||||
laserPointerToolIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
@@ -215,18 +215,23 @@ export const SelectedShapeActions = ({
|
||||
export const ShapesSwitcher = ({
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
app,
|
||||
}: {
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
@@ -251,29 +256,14 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
activeEmbeddable: null,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
app.setActiveTool({ type: value });
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
@@ -300,24 +290,14 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
app.setActiveTool({ type: "frame" });
|
||||
}}
|
||||
selected={activeTool.type === "frame"}
|
||||
/>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
@@ -330,30 +310,28 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-embeddable`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "embeddable", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
app.setActiveTool({ type: "embeddable" });
|
||||
}}
|
||||
selected={activeTool.type === "embeddable"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="App-toolbar__extra-tools-trigger"
|
||||
className={clsx("App-toolbar__extra-tools-trigger", {
|
||||
"App-toolbar__extra-tools-trigger--selected":
|
||||
frameToolSelected ||
|
||||
embeddableToolSelected ||
|
||||
// in collab we're already highlighting the laser button
|
||||
// outside toolbar, so let's not highlight extra-tools button
|
||||
// on top of it
|
||||
(laserToolSelected && !app.props.isCollaborating),
|
||||
})}
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
@@ -366,37 +344,36 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
app.setActiveTool({ type: "frame" });
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
app.setActiveTool({ type: "embeddable" });
|
||||
}}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "laser" });
|
||||
}}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
@@ -35,6 +35,7 @@ import {
|
||||
actionLink,
|
||||
actionToggleElementLock,
|
||||
actionToggleLinearEditor,
|
||||
actionToggleObjectsSnapMode,
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
@@ -210,7 +211,7 @@ import {
|
||||
import Scene from "../scene/Scene";
|
||||
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { findShapeByKey, SHAPES } from "../shapes";
|
||||
import { findShapeByKey } from "../shapes";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
@@ -228,6 +229,9 @@ import {
|
||||
FrameNameBoundsCache,
|
||||
SidebarName,
|
||||
SidebarTabName,
|
||||
KeyboardModifiersObject,
|
||||
CollaboratorPointer,
|
||||
ToolType,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -342,6 +346,17 @@ import {
|
||||
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import {
|
||||
getSnapLinesAtPointer,
|
||||
snapDraggedElements,
|
||||
isActiveToolNonLinearSnappable,
|
||||
snapNewElement,
|
||||
snapResizingElements,
|
||||
isSnappingEnabled,
|
||||
getVisibleGaps,
|
||||
getReferenceSnapPoints,
|
||||
SnapCache,
|
||||
} from "../snapping";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||
@@ -354,6 +369,8 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -478,10 +495,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
|
||||
|
||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||
lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
|
||||
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
||||
null;
|
||||
lastViewportPosition = { x: 0, y: 0 };
|
||||
|
||||
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
@@ -490,6 +510,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
objectsSnapModeEnabled = false,
|
||||
theme = defaultAppState.theme,
|
||||
name = defaultAppState.name,
|
||||
} = props;
|
||||
@@ -500,6 +521,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...this.getCanvasOffsets(),
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
objectsSnapModeEnabled,
|
||||
gridSize: gridModeEnabled ? GRID_SIZE : null,
|
||||
name,
|
||||
width: window.innerWidth,
|
||||
@@ -1086,7 +1108,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
cursor: CURSOR_TYPE.MOVE,
|
||||
pointerEvents: this.state.viewModeEnabled
|
||||
? POINTER_EVENTS.disabled
|
||||
: POINTER_EVENTS.inheritFromUI,
|
||||
: POINTER_EVENTS.enabled,
|
||||
}}
|
||||
onPointerDown={(event) => this.handleCanvasPointerDown(event)}
|
||||
onWheel={(event) => this.handleWheel(event)}
|
||||
@@ -1188,12 +1210,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<LaserToolOverlay manager={this.laserPathManager} />
|
||||
{selectedElements.length === 1 &&
|
||||
!this.state.contextMenu &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
@@ -1330,7 +1354,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
||||
jotaiStore.set(activeEyeDropperAtom, {
|
||||
swapPreviewOnAlt: true,
|
||||
previewType: type === "stroke" ? "strokeColor" : "backgroundColor",
|
||||
colorPickerType:
|
||||
type === "stroke" ? "elementStroke" : "elementBackground",
|
||||
onSelect: (color, event) => {
|
||||
const shouldUpdateStrokeColor =
|
||||
(type === "background" && event.altKey) ||
|
||||
@@ -1341,12 +1366,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type !== "selection"
|
||||
) {
|
||||
if (shouldUpdateStrokeColor) {
|
||||
this.setState({
|
||||
currentItemStrokeColor: color,
|
||||
this.syncActionResult({
|
||||
appState: { ...this.state, currentItemStrokeColor: color },
|
||||
commitToHistory: true,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
currentItemBackgroundColor: color,
|
||||
this.syncActionResult({
|
||||
appState: { ...this.state, currentItemBackgroundColor: color },
|
||||
commitToHistory: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -1718,7 +1745,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.removeEventListeners();
|
||||
this.scene.destroy();
|
||||
this.library.destroy();
|
||||
this.laserPathManager.destroy();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
isSomeElementSelected.clearCache();
|
||||
selectGroupsForSelectedElements.clearCache();
|
||||
@@ -2533,10 +2562,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
togglePenMode = () => {
|
||||
togglePenMode = (force?: boolean) => {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
penMode: !prevState.penMode,
|
||||
penMode: force ?? !prevState.penMode,
|
||||
penDetected: true,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -3030,6 +3060,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.setActiveTool({ type: "selection" });
|
||||
} else {
|
||||
this.setActiveTool({ type: "laser" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||
@@ -3089,15 +3128,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
});
|
||||
|
||||
private setActiveTool = (
|
||||
setActiveTool = (
|
||||
tool:
|
||||
| {
|
||||
type:
|
||||
| typeof SHAPES[number]["value"]
|
||||
| "eraser"
|
||||
| "hand"
|
||||
| "frame"
|
||||
| "embeddable";
|
||||
type: ToolType;
|
||||
}
|
||||
| { type: "custom"; customType: string },
|
||||
) => {
|
||||
@@ -3116,17 +3150,30 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (nextActiveTool.type === "image") {
|
||||
this.onImageAction();
|
||||
}
|
||||
if (nextActiveTool.type !== "selection") {
|
||||
this.setState({
|
||||
activeTool: nextActiveTool,
|
||||
selectedElementIds: makeNextSelectedElementIds({}, this.state),
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
|
||||
this.setState((prevState) => {
|
||||
const commonResets = {
|
||||
snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
|
||||
originSnapOffset: null,
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
|
||||
}
|
||||
} as const;
|
||||
if (nextActiveTool.type !== "selection") {
|
||||
return {
|
||||
...prevState,
|
||||
activeTool: nextActiveTool,
|
||||
selectedElementIds: makeNextSelectedElementIds({}, prevState),
|
||||
selectedGroupIds: makeNextSelectedElementIds({}, prevState),
|
||||
editingGroupId: null,
|
||||
multiElement: null,
|
||||
...commonResets,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prevState,
|
||||
activeTool: nextActiveTool,
|
||||
...commonResets,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
private setCursor = (cursor: string) => {
|
||||
@@ -3703,10 +3750,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isTouchScreen: boolean,
|
||||
) => {
|
||||
const draggedDistance = distance2d(
|
||||
this.lastPointerDown!.clientX,
|
||||
this.lastPointerDown!.clientY,
|
||||
this.lastPointerUp!.clientX,
|
||||
this.lastPointerUp!.clientY,
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
);
|
||||
if (
|
||||
!this.hitLinkElement ||
|
||||
@@ -3717,7 +3764,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
const lastPointerDownCoords = viewportCoordsToSceneCoords(
|
||||
this.lastPointerDown!,
|
||||
this.lastPointerDownEvent!,
|
||||
this.state,
|
||||
);
|
||||
const lastPointerDownHittingLinkIcon = isPointHittingLink(
|
||||
@@ -3727,7 +3774,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.device.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
this.lastPointerUp!,
|
||||
this.lastPointerUpEvent!,
|
||||
this.state,
|
||||
);
|
||||
const lastPointerUpHittingLinkIcon = isPointHittingLink(
|
||||
@@ -3862,6 +3909,30 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
|
||||
const { x: scenePointerX, y: scenePointerY } = scenePointer;
|
||||
|
||||
if (
|
||||
!this.state.draggingElement &&
|
||||
isActiveToolNonLinearSnappable(this.state.activeTool.type)
|
||||
) {
|
||||
const { originOffset, snapLines } = getSnapLinesAtPointer(
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
{
|
||||
x: scenePointerX,
|
||||
y: scenePointerY,
|
||||
},
|
||||
event,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
originSnapOffset: originOffset,
|
||||
});
|
||||
} else if (!this.state.draggingElement) {
|
||||
this.setState({
|
||||
snapLines: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
!this.state.editingLinearElement.isDragging
|
||||
@@ -4332,6 +4403,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ contextMenu: null });
|
||||
}
|
||||
|
||||
if (this.state.snapLines) {
|
||||
this.setAppState({ snapLines: [] });
|
||||
}
|
||||
|
||||
this.updateGestureOnPointerDown(event);
|
||||
|
||||
// if dragging element is freedraw and another pointerdown event occurs
|
||||
@@ -4404,17 +4479,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPointerDown = event;
|
||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPointerDownEvent = event;
|
||||
|
||||
this.setState({
|
||||
lastPointerDownWith: event.pointerType,
|
||||
cursorButton: "down",
|
||||
});
|
||||
this.savePointer(event.clientX, event.clientY, "down");
|
||||
|
||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only handle left mouse button or touch
|
||||
if (
|
||||
event.button !== POINTER_BUTTON.MAIN &&
|
||||
@@ -4505,6 +4581,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
} else if (this.state.activeTool.type === "frame") {
|
||||
this.createFrameElementOnPointerDown(pointerDownState);
|
||||
} else if (this.state.activeTool.type === "laser") {
|
||||
this.laserPathManager.startPath(
|
||||
pointerDownState.lastCoords.x,
|
||||
pointerDownState.lastCoords.y,
|
||||
);
|
||||
} else if (
|
||||
this.state.activeTool.type !== "eraser" &&
|
||||
this.state.activeTool.type !== "hand"
|
||||
@@ -4528,7 +4609,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
lastPointerUp = onPointerUp;
|
||||
|
||||
if (!this.state.viewModeEnabled) {
|
||||
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
|
||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||
@@ -4544,14 +4625,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
this.removePointer(event);
|
||||
this.lastPointerUp = event;
|
||||
this.lastPointerUpEvent = event;
|
||||
|
||||
const scenePointer = viewportCoordsToSceneCoords(
|
||||
{ clientX: event.clientX, clientY: event.clientY },
|
||||
this.state,
|
||||
);
|
||||
const clicklength =
|
||||
event.timeStamp - (this.lastPointerDown?.timeStamp ?? 0);
|
||||
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
||||
if (this.device.isMobile && clicklength < 300) {
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
@@ -5305,7 +5386,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
);
|
||||
|
||||
const embedLink = getEmbedLink(link);
|
||||
@@ -5355,7 +5438,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
);
|
||||
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
@@ -5532,7 +5617,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
);
|
||||
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
@@ -5590,7 +5677,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
);
|
||||
|
||||
const frame = newFrameElement({
|
||||
@@ -5613,6 +5702,52 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
private maybeCacheReferenceSnapPoints(
|
||||
event: KeyboardModifiersObject,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
recomputeAnyways: boolean = false,
|
||||
) {
|
||||
if (
|
||||
isSnappingEnabled({
|
||||
event,
|
||||
appState: this.state,
|
||||
selectedElements,
|
||||
}) &&
|
||||
(recomputeAnyways || !SnapCache.getReferenceSnapPoints())
|
||||
) {
|
||||
SnapCache.setReferenceSnapPoints(
|
||||
getReferenceSnapPoints(
|
||||
this.scene.getNonDeletedElements(),
|
||||
selectedElements,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeCacheVisibleGaps(
|
||||
event: KeyboardModifiersObject,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
recomputeAnyways: boolean = false,
|
||||
) {
|
||||
if (
|
||||
isSnappingEnabled({
|
||||
event,
|
||||
appState: this.state,
|
||||
selectedElements,
|
||||
}) &&
|
||||
(recomputeAnyways || !SnapCache.getVisibleGaps())
|
||||
) {
|
||||
SnapCache.setVisibleGaps(
|
||||
getVisibleGaps(
|
||||
this.scene.getNonDeletedElements(),
|
||||
selectedElements,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyDownFromPointerDownHandler(
|
||||
pointerDownState: PointerDownState,
|
||||
): (event: KeyboardEvent) => void {
|
||||
@@ -5670,6 +5805,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
}
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
@@ -5842,33 +5981,62 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.editingElement &&
|
||||
this.state.activeEmbeddable?.state !== "active"
|
||||
) {
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
const dragOffset = {
|
||||
x: pointerCoords.x - pointerDownState.origin.x,
|
||||
y: pointerCoords.y - pointerDownState.origin.y,
|
||||
};
|
||||
|
||||
const [dragDistanceX, dragDistanceY] = [
|
||||
Math.abs(pointerCoords.x - pointerDownState.origin.x),
|
||||
Math.abs(pointerCoords.y - pointerDownState.origin.y),
|
||||
const originalElements = [
|
||||
...pointerDownState.originalElements.values(),
|
||||
];
|
||||
|
||||
// We only drag in one direction if shift is pressed
|
||||
const lockDirection = event.shiftKey;
|
||||
|
||||
if (lockDirection) {
|
||||
const distanceX = Math.abs(dragOffset.x);
|
||||
const distanceY = Math.abs(dragOffset.y);
|
||||
|
||||
const lockX = lockDirection && distanceX < distanceY;
|
||||
const lockY = lockDirection && distanceX > distanceY;
|
||||
|
||||
if (lockX) {
|
||||
dragOffset.x = 0;
|
||||
}
|
||||
|
||||
if (lockY) {
|
||||
dragOffset.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Snap cache *must* be synchronously popuplated before initial drag,
|
||||
// otherwise the first drag even will not snap, causing a jump before
|
||||
// it snaps to its position if previously snapped already.
|
||||
this.maybeCacheVisibleGaps(event, selectedElements);
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements);
|
||||
|
||||
const { snapOffset, snapLines } = snapDraggedElements(
|
||||
getSelectedElements(originalElements, this.state),
|
||||
dragOffset,
|
||||
this.state,
|
||||
event,
|
||||
);
|
||||
|
||||
this.setState({ snapLines });
|
||||
|
||||
// when we're editing the name of a frame, we want the user to be
|
||||
// able to select and interact with the text input
|
||||
!this.state.editingFrame &&
|
||||
dragSelectedElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
dragX,
|
||||
dragY,
|
||||
lockDirection,
|
||||
dragDistanceX,
|
||||
dragDistanceY,
|
||||
dragOffset,
|
||||
this.state,
|
||||
this.scene,
|
||||
snapOffset,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
|
||||
// We duplicate the selected element if alt is pressed on pointer move
|
||||
@@ -5909,15 +6077,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
groupIdMap,
|
||||
element,
|
||||
);
|
||||
const [originDragX, originDragY] = getGridPoint(
|
||||
pointerDownState.origin.x - pointerDownState.drag.offset.x,
|
||||
pointerDownState.origin.y - pointerDownState.drag.offset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
const origElement = pointerDownState.originalElements.get(
|
||||
element.id,
|
||||
)!;
|
||||
mutateElement(duplicatedElement, {
|
||||
x: duplicatedElement.x + (originDragX - dragX),
|
||||
y: duplicatedElement.y + (originDragY - dragY),
|
||||
x: origElement.x,
|
||||
y: origElement.y,
|
||||
});
|
||||
|
||||
// put duplicated element to pointerDownState.originalElements
|
||||
// so that we can snap to the duplicated element without releasing
|
||||
pointerDownState.originalElements.set(
|
||||
duplicatedElement.id,
|
||||
duplicatedElement,
|
||||
);
|
||||
|
||||
nextElements.push(duplicatedElement);
|
||||
elementsToAppend.push(element);
|
||||
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
|
||||
@@ -5943,6 +6117,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
this.scene.replaceAllElements(nextSceneElements);
|
||||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6159,6 +6335,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isResizing,
|
||||
isRotating,
|
||||
} = this.state;
|
||||
|
||||
this.setState({
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@@ -6173,8 +6350,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
multiElement || isTextElement(this.state.editingElement)
|
||||
? this.state.editingElement
|
||||
: null,
|
||||
snapLines: [],
|
||||
|
||||
originSnapOffset: null,
|
||||
});
|
||||
|
||||
SnapCache.setReferenceSnapPoints(null);
|
||||
SnapCache.setVisibleGaps(null);
|
||||
|
||||
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
||||
|
||||
this.setState({
|
||||
@@ -6398,7 +6581,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
// remove invisible element which was added in onPointerDown
|
||||
this.scene.replaceAllElements(
|
||||
this.scene.getElementsIncludingDeleted().slice(0, -1),
|
||||
this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((el) => el.id !== draggingElement.id),
|
||||
);
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
@@ -6620,17 +6805,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
if (isEraserActive(this.state)) {
|
||||
const draggedDistance = distance2d(
|
||||
this.lastPointerDown!.clientX,
|
||||
this.lastPointerDown!.clientY,
|
||||
this.lastPointerUp!.clientX,
|
||||
this.lastPointerUp!.clientY,
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
);
|
||||
|
||||
if (draggedDistance === 0) {
|
||||
const scenePointer = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: this.lastPointerUp!.clientX,
|
||||
clientY: this.lastPointerUp!.clientY,
|
||||
clientX: this.lastPointerUpEvent!.clientX,
|
||||
clientY: this.lastPointerUpEvent!.clientY,
|
||||
},
|
||||
this.state,
|
||||
);
|
||||
@@ -6870,6 +7055,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: unbindLinearElements)(this.scene.getSelectedElements(this.state));
|
||||
}
|
||||
|
||||
if (activeTool.type === "laser") {
|
||||
this.laserPathManager.endPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.setState({
|
||||
@@ -6886,14 +7076,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (
|
||||
hitElement &&
|
||||
this.lastPointerUp &&
|
||||
this.lastPointerDown &&
|
||||
this.lastPointerUp.timeStamp - this.lastPointerDown.timeStamp < 300 &&
|
||||
this.lastPointerUpEvent &&
|
||||
this.lastPointerDownEvent &&
|
||||
this.lastPointerUpEvent.timeStamp -
|
||||
this.lastPointerDownEvent.timeStamp <
|
||||
300 &&
|
||||
gesture.pointers.size <= 1 &&
|
||||
isEmbeddableElement(hitElement) &&
|
||||
this.isEmbeddableCenter(
|
||||
hitElement,
|
||||
this.lastPointerUp,
|
||||
this.lastPointerUpEvent,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
)
|
||||
@@ -7702,7 +7894,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
shouldResizeFromCenter(event),
|
||||
);
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
let [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
@@ -7716,6 +7908,33 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? image.width / image.height
|
||||
: null;
|
||||
|
||||
this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
|
||||
|
||||
const { snapOffset, snapLines } = snapNewElement(
|
||||
draggingElement,
|
||||
this.state,
|
||||
event,
|
||||
{
|
||||
x:
|
||||
pointerDownState.originInGrid.x +
|
||||
(this.state.originSnapOffset?.x ?? 0),
|
||||
y:
|
||||
pointerDownState.originInGrid.y +
|
||||
(this.state.originSnapOffset?.y ?? 0),
|
||||
},
|
||||
{
|
||||
x: gridX - pointerDownState.originInGrid.x,
|
||||
y: gridY - pointerDownState.originInGrid.y,
|
||||
},
|
||||
);
|
||||
|
||||
gridX += snapOffset.x;
|
||||
gridY += snapOffset.y;
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
});
|
||||
|
||||
dragNewElement(
|
||||
draggingElement,
|
||||
this.state.activeTool.type,
|
||||
@@ -7730,6 +7949,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: shouldMaintainAspectRatio(event),
|
||||
shouldResizeFromCenter(event),
|
||||
aspectRatio,
|
||||
this.state.originSnapOffset,
|
||||
);
|
||||
|
||||
this.maybeSuggestBindingForAll([draggingElement]);
|
||||
@@ -7771,7 +7991,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
const pointerCoords = pointerDownState.lastCoords;
|
||||
const [resizeX, resizeY] = getGridPoint(
|
||||
let [resizeX, resizeY] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.resize.offset.x,
|
||||
pointerCoords.y - pointerDownState.resize.offset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
@@ -7799,6 +8019,41 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
});
|
||||
|
||||
// check needed for avoiding flickering when a key gets pressed
|
||||
// during dragging
|
||||
if (!this.state.selectedElementsAreBeingDragged) {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const dragOffset = {
|
||||
x: gridX - pointerDownState.originInGrid.x,
|
||||
y: gridY - pointerDownState.originInGrid.y,
|
||||
};
|
||||
|
||||
const originalElements = [...pointerDownState.originalElements.values()];
|
||||
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements);
|
||||
|
||||
const { snapOffset, snapLines } = snapResizingElements(
|
||||
selectedElements,
|
||||
getSelectedElements(originalElements, this.state),
|
||||
this.state,
|
||||
event,
|
||||
dragOffset,
|
||||
transformHandleType,
|
||||
);
|
||||
|
||||
resizeX += snapOffset.x;
|
||||
resizeY += snapOffset.y;
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
transformElements(
|
||||
pointerDownState,
|
||||
@@ -7814,6 +8069,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resizeY,
|
||||
pointerDownState.resize.center.x,
|
||||
pointerDownState.resize.center.y,
|
||||
this.state,
|
||||
)
|
||||
) {
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
@@ -7901,6 +8157,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionUnlockAllElements,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionToggleGridMode,
|
||||
actionToggleObjectsSnapMode,
|
||||
actionToggleZenMode,
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
@@ -8047,15 +8304,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!x || !y) {
|
||||
return;
|
||||
}
|
||||
const pointer = viewportCoordsToSceneCoords(
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
{ clientX: x, clientY: y },
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (isNaN(pointer.x) || isNaN(pointer.y)) {
|
||||
if (isNaN(sceneX) || isNaN(sceneY)) {
|
||||
// sometimes the pointer goes off screen
|
||||
}
|
||||
|
||||
const pointer: CollaboratorPointer = {
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
|
||||
};
|
||||
|
||||
this.props.onPointerUpdate?.({
|
||||
pointer,
|
||||
button,
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getColor } from "./ColorPicker";
|
||||
import { useAtom } from "jotai";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
} from "./colorPickerUtils";
|
||||
import { eyeDropperIcon } from "../icons";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { KEYS } from "../../keys";
|
||||
@@ -15,14 +18,14 @@ interface ColorInputProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
eyeDropperType: "strokeColor" | "backgroundColor";
|
||||
colorPickerType: ColorPickerType;
|
||||
}
|
||||
|
||||
export const ColorInput = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
eyeDropperType,
|
||||
colorPickerType,
|
||||
}: ColorInputProps) => {
|
||||
const device = useDevice();
|
||||
const [innerValue, setInnerValue] = useState(color);
|
||||
@@ -116,7 +119,7 @@ export const ColorInput = ({
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: (color) => onChange(color),
|
||||
previewType: eyeDropperType,
|
||||
colorPickerType,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@@ -82,14 +82,7 @@ const ColorPickerPopupContent = ({
|
||||
const { container } = useExcalidrawContainer();
|
||||
const { isMobile, isLandscape } = useDevice();
|
||||
|
||||
const eyeDropperType =
|
||||
type === "canvasBackground"
|
||||
? undefined
|
||||
: type === "elementBackground"
|
||||
? "backgroundColor"
|
||||
: "strokeColor";
|
||||
|
||||
const colorInputJSX = eyeDropperType && (
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
<ColorInput
|
||||
@@ -98,7 +91,7 @@ const ColorPickerPopupContent = ({
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
}}
|
||||
eyeDropperType={eyeDropperType}
|
||||
colorPickerType={type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -160,7 +153,7 @@ const ColorPickerPopupContent = ({
|
||||
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
||||
}}
|
||||
>
|
||||
{palette && eyeDropperType ? (
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
@@ -173,7 +166,7 @@ const ColorPickerPopupContent = ({
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: onChange,
|
||||
previewType: eyeDropperType,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
@@ -184,7 +177,7 @@ const ColorPickerPopupContent = ({
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
previewType: eyeDropperType,
|
||||
colorPickerType: type,
|
||||
};
|
||||
});
|
||||
}}
|
||||
|
@@ -1,35 +1,47 @@
|
||||
import { atom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { rgbToHex } from "../colors";
|
||||
import { EVENT } from "../constants";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
import { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
|
||||
type EyeDropperProperties = {
|
||||
export type EyeDropperProperties = {
|
||||
keepOpenOnAlt: boolean;
|
||||
swapPreviewOnAlt?: boolean;
|
||||
/** called when user picks color (on pointerup) */
|
||||
onSelect: (color: string, event: PointerEvent) => void;
|
||||
previewType: "strokeColor" | "backgroundColor";
|
||||
/**
|
||||
* property of selected elements to update live when alt-dragging.
|
||||
* Supply `null` if not applicable (e.g. updating the canvas bg instead of
|
||||
* elements)
|
||||
**/
|
||||
colorPickerType: ColorPickerType;
|
||||
};
|
||||
|
||||
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
|
||||
|
||||
export const EyeDropper: React.FC<{
|
||||
onCancel: () => void;
|
||||
onSelect: Required<EyeDropperProperties>["onSelect"];
|
||||
swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"];
|
||||
previewType: EyeDropperProperties["previewType"];
|
||||
}> = ({ onCancel, onSelect, swapPreviewOnAlt, previewType }) => {
|
||||
onSelect: EyeDropperProperties["onSelect"];
|
||||
/** called when color changes, on pointerdown for preview */
|
||||
onChange: (
|
||||
type: ColorPickerType,
|
||||
color: string,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
event: { altKey: boolean },
|
||||
) => void;
|
||||
colorPickerType: EyeDropperProperties["colorPickerType"];
|
||||
}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
|
||||
const eyeDropperContainer = useCreatePortalContainer({
|
||||
className: "excalidraw-eye-dropper-backdrop",
|
||||
parentSelector: ".excalidraw-eye-dropper-container",
|
||||
@@ -40,9 +52,13 @@ export const EyeDropper: React.FC<{
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const metaStuffRef = useRef({ selectedElements, app });
|
||||
metaStuffRef.current.selectedElements = selectedElements;
|
||||
metaStuffRef.current.app = app;
|
||||
const stableProps = useStable({
|
||||
app,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSelect,
|
||||
selectedElements,
|
||||
});
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
@@ -90,28 +106,28 @@ export const EyeDropper: React.FC<{
|
||||
const currentColor = getCurrentColor({ clientX, clientY });
|
||||
|
||||
if (isHoldingPointerDown) {
|
||||
for (const element of metaStuffRef.current.selectedElements) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
[altKey && swapPreviewOnAlt
|
||||
? previewType === "strokeColor"
|
||||
? "backgroundColor"
|
||||
: "strokeColor"
|
||||
: previewType]: currentColor,
|
||||
},
|
||||
false,
|
||||
);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(
|
||||
metaStuffRef.current.selectedElements[0],
|
||||
)?.informMutation();
|
||||
stableProps.onChange(
|
||||
colorPickerType,
|
||||
currentColor,
|
||||
stableProps.selectedElements,
|
||||
{ altKey },
|
||||
);
|
||||
}
|
||||
|
||||
colorPreviewDiv.style.background = currentColor;
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
stableProps.onCancel();
|
||||
};
|
||||
|
||||
const onSelect: Required<EyeDropperProperties>["onSelect"] = (
|
||||
color,
|
||||
event,
|
||||
) => {
|
||||
stableProps.onSelect(color, event);
|
||||
};
|
||||
|
||||
const pointerDownListener = (event: PointerEvent) => {
|
||||
isHoldingPointerDown = true;
|
||||
// NOTE we can't event.preventDefault() as that would stop
|
||||
@@ -148,8 +164,8 @@ export const EyeDropper: React.FC<{
|
||||
|
||||
// init color preview else it would show only after the first mouse move
|
||||
mouseMoveListener({
|
||||
clientX: metaStuffRef.current.app.lastViewportPosition.x,
|
||||
clientY: metaStuffRef.current.app.lastViewportPosition.y,
|
||||
clientX: stableProps.app.lastViewportPosition.x,
|
||||
clientY: stableProps.app.lastViewportPosition.y,
|
||||
altKey: false,
|
||||
});
|
||||
|
||||
@@ -179,12 +195,10 @@ export const EyeDropper: React.FC<{
|
||||
window.removeEventListener(EVENT.BLUR, onCancel);
|
||||
};
|
||||
}, [
|
||||
stableProps,
|
||||
app.canvas,
|
||||
eyeDropperContainer,
|
||||
onCancel,
|
||||
onSelect,
|
||||
swapPreviewOnAlt,
|
||||
previewType,
|
||||
colorPickerType,
|
||||
excalidrawContainer,
|
||||
appState.offsetLeft,
|
||||
appState.offsetTop,
|
||||
|
@@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
@@ -258,6 +259,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("buttons.zenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.objectsSnapMode")}
|
||||
shortcuts={[getShortcutKey("Alt+S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showGrid")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
|
316
src/components/LaserTool/LaserPathManager.ts
Normal file
316
src/components/LaserTool/LaserPathManager.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import { sceneCoordsToViewportCoords } from "../../utils";
|
||||
import App from "../App";
|
||||
import { getClientColor } from "../../clients";
|
||||
|
||||
// decay time in milliseconds
|
||||
const DECAY_TIME = 1000;
|
||||
// length of line in points before it starts decaying
|
||||
const DECAY_LENGTH = 50;
|
||||
|
||||
const average = (a: number, b: number) => (a + b) / 2;
|
||||
function getSvgPathFromStroke(points: number[][], closed = true) {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
|
||||
2,
|
||||
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
|
||||
b[1],
|
||||
c[1],
|
||||
).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
|
||||
2,
|
||||
)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += "Z";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LPM: LaserPathManager;
|
||||
}
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function instantiateCollabolatorState(): CollabolatorState {
|
||||
return {
|
||||
currentPath: undefined,
|
||||
finishedPaths: [],
|
||||
lastPoint: [-10000, -10000],
|
||||
svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
|
||||
};
|
||||
}
|
||||
|
||||
function instantiatePath() {
|
||||
LaserPointer.constants.cornerDetectionMaxAngle = 70;
|
||||
|
||||
return new LaserPointer({
|
||||
simplify: 0,
|
||||
streamline: 0.4,
|
||||
sizeMapping: (c) => {
|
||||
const pt = DECAY_TIME;
|
||||
const pl = DECAY_LENGTH;
|
||||
const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
|
||||
const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
|
||||
|
||||
return Math.min(easeOutCubic(l), easeOutCubic(t));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CollabolatorState = {
|
||||
currentPath: LaserPointer | undefined;
|
||||
finishedPaths: LaserPointer[];
|
||||
lastPoint: [number, number];
|
||||
svg: SVGPathElement;
|
||||
};
|
||||
|
||||
export class LaserPathManager {
|
||||
private ownState: CollabolatorState;
|
||||
private collaboratorsState: Map<string, CollabolatorState> = new Map();
|
||||
|
||||
private rafId: number | undefined;
|
||||
private isDrawing = false;
|
||||
private container: SVGSVGElement | undefined;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.isDrawing = false;
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
this.collaboratorsState = new Map();
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
if (this.container) {
|
||||
this.container.style.top = "0px";
|
||||
this.container.style.left = "0px";
|
||||
const { x, y } = this.container.getBoundingClientRect();
|
||||
this.container.style.top = `${-y}px`;
|
||||
this.container.style.left = `${-x}px`;
|
||||
}
|
||||
this.ownState.currentPath = instantiatePath();
|
||||
this.ownState.currentPath.addPoint([x, y, performance.now()]);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
|
||||
addPointToPath(x: number, y: number) {
|
||||
if (this.ownState.currentPath) {
|
||||
this.ownState.currentPath?.addPoint([x, y, performance.now()]);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
}
|
||||
|
||||
endPath() {
|
||||
if (this.ownState.currentPath) {
|
||||
this.ownState.currentPath.close();
|
||||
this.ownState.finishedPaths.push(this.ownState.currentPath);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePath(state: CollabolatorState) {
|
||||
this.isDrawing = true;
|
||||
|
||||
if (!this.isRunning) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
private isRunning = false;
|
||||
|
||||
start(svg?: SVGSVGElement) {
|
||||
if (svg) {
|
||||
this.container = svg;
|
||||
this.container.appendChild(this.ownState.svg);
|
||||
}
|
||||
|
||||
this.stop();
|
||||
this.isRunning = true;
|
||||
this.loop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
this.rafId = undefined;
|
||||
}
|
||||
|
||||
loop() {
|
||||
this.rafId = requestAnimationFrame(this.loop.bind(this));
|
||||
|
||||
this.updateCollabolatorsState();
|
||||
|
||||
if (this.isDrawing) {
|
||||
this.update();
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
draw(path: LaserPointer) {
|
||||
const stroke = path
|
||||
.getStrokeOutline(path.options.size / this.app.state.zoom.value)
|
||||
.map(([x, y]) => {
|
||||
const result = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x, sceneY: y },
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
return [result.x, result.y];
|
||||
});
|
||||
|
||||
return getSvgPathFromStroke(stroke, true);
|
||||
}
|
||||
|
||||
updateCollabolatorsState() {
|
||||
if (!this.container || !this.app.state.collaborators.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
|
||||
if (!this.collaboratorsState.has(key)) {
|
||||
const state = instantiateCollabolatorState();
|
||||
this.container.appendChild(state.svg);
|
||||
this.collaboratorsState.set(key, state);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
|
||||
const state = this.collaboratorsState.get(key)!;
|
||||
|
||||
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
|
||||
if (collabolator.button === "down" && state.currentPath === undefined) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath = instantiatePath();
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
|
||||
if (collabolator.button === "down" && state.currentPath !== undefined) {
|
||||
if (
|
||||
collabolator.pointer.x !== state.lastPoint[0] ||
|
||||
collabolator.pointer.y !== state.lastPoint[1]
|
||||
) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (collabolator.button === "up" && state.currentPath !== undefined) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
state.currentPath.close();
|
||||
|
||||
state.finishedPaths.push(state.currentPath);
|
||||
state.currentPath = undefined;
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let somePathsExist = false;
|
||||
|
||||
for (const [key, state] of this.collaboratorsState.entries()) {
|
||||
if (!this.app.state.collaborators.has(key)) {
|
||||
state.svg.remove();
|
||||
this.collaboratorsState.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.finishedPaths = state.finishedPaths.filter((path) => {
|
||||
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||
|
||||
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||
});
|
||||
|
||||
let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
|
||||
|
||||
if (state.currentPath) {
|
||||
paths += ` ${this.draw(state.currentPath)}`;
|
||||
}
|
||||
|
||||
if (paths.trim()) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
state.svg.setAttribute("d", paths);
|
||||
state.svg.setAttribute("fill", getClientColor(key));
|
||||
}
|
||||
|
||||
this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
|
||||
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||
|
||||
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||
});
|
||||
|
||||
let paths = this.ownState.finishedPaths
|
||||
.map((path) => this.draw(path))
|
||||
.join(" ");
|
||||
|
||||
if (this.ownState.currentPath) {
|
||||
paths += ` ${this.draw(this.ownState.currentPath)}`;
|
||||
}
|
||||
|
||||
paths = paths.trim();
|
||||
|
||||
if (paths) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
this.ownState.svg.setAttribute("d", paths);
|
||||
this.ownState.svg.setAttribute("fill", "red");
|
||||
|
||||
if (!somePathsExist) {
|
||||
this.isDrawing = false;
|
||||
}
|
||||
}
|
||||
}
|
41
src/components/LaserTool/LaserPointerButton.tsx
Normal file
41
src/components/LaserTool/LaserPointerButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import "../ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "../ToolButton";
|
||||
import { laserPointerToolIcon } from "../icons";
|
||||
|
||||
type LaserPointerIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||
|
||||
export const LaserPointerButton = (props: LaserPointerIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__LaserPointer",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"is-mobile": props.isMobile,
|
||||
},
|
||||
)}
|
||||
title={`${props.title}`}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={props.title}
|
||||
data-testid="toolbar-LaserPointer"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{laserPointerToolIcon}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
27
src/components/LaserTool/LaserTool.tsx
Normal file
27
src/components/LaserTool/LaserTool.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LaserPathManager } from "./LaserPathManager";
|
||||
import "./LaserToolOverlay.scss";
|
||||
|
||||
type LaserToolOverlayProps = {
|
||||
manager: LaserPathManager;
|
||||
};
|
||||
|
||||
export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current) {
|
||||
manager.start(svgRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
manager.stop();
|
||||
};
|
||||
}, [manager]);
|
||||
|
||||
return (
|
||||
<div className="LaserToolOverlay">
|
||||
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
|
||||
</div>
|
||||
);
|
||||
};
|
19
src/components/LaserTool/LaserToolOverlay.scss
Normal file
19
src/components/LaserTool/LaserToolOverlay.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.excalidraw {
|
||||
.LaserToolOverlay {
|
||||
pointer-events: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
|
||||
.LaserToolOverlayCanvas {
|
||||
image-rendering: auto;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
@@ -52,6 +52,10 @@ import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -74,6 +78,7 @@ interface LayerUIProps {
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
isCollaborating: boolean;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@@ -131,6 +136,7 @@ const LayerUI = ({
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
app,
|
||||
isCollaborating,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
@@ -276,7 +282,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
app={app}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
@@ -285,6 +291,24 @@ const LayerUI = ({
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={appState.activeTool.type === "laser"}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: "laser" })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
@@ -368,11 +392,44 @@ const LayerUI = ({
|
||||
)}
|
||||
{eyeDropperState && !device.isMobile && (
|
||||
<EyeDropper
|
||||
swapPreviewOnAlt={eyeDropperState.swapPreviewOnAlt}
|
||||
previewType={eyeDropperState.previewType}
|
||||
colorPickerType={eyeDropperState.colorPickerType}
|
||||
onCancel={() => {
|
||||
setEyeDropperState(null);
|
||||
}}
|
||||
onChange={(colorPickerType, color, selectedElements, { altKey }) => {
|
||||
if (
|
||||
colorPickerType !== "elementBackground" &&
|
||||
colorPickerType !== "elementStroke"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedElements.length) {
|
||||
for (const element of selectedElements) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
[altKey && eyeDropperState.swapPreviewOnAlt
|
||||
? colorPickerType === "elementBackground"
|
||||
? "strokeColor"
|
||||
: "backgroundColor"
|
||||
: colorPickerType === "elementBackground"
|
||||
? "backgroundColor"
|
||||
: "strokeColor"]: color,
|
||||
},
|
||||
false,
|
||||
);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(selectedElements[0])?.informMutation();
|
||||
} else if (colorPickerType === "elementBackground") {
|
||||
setAppState({
|
||||
currentItemBackgroundColor: color,
|
||||
});
|
||||
} else {
|
||||
setAppState({ currentItemStrokeColor: color });
|
||||
}
|
||||
}}
|
||||
onSelect={(color, event) => {
|
||||
setEyeDropperState((state) => {
|
||||
return state?.keepOpenOnAlt && event.altKey ? state : null;
|
||||
|
@@ -87,7 +87,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
app={app}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
|
@@ -170,5 +170,10 @@
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__LaserPointer .ToolIcon__icon {
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -28,6 +28,12 @@
|
||||
box-shadow: 0 0 0 1px
|
||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||
}
|
||||
|
||||
&--selected,
|
||||
&--selected:hover {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-dropdown {
|
||||
|
@@ -193,6 +193,8 @@ const getRelevantAppStateProps = (
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
@@ -59,6 +59,11 @@
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
@@ -11,12 +11,14 @@ const DropdownMenuItem = ({
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
@@ -26,7 +28,7 @@ const DropdownMenuItem = ({
|
||||
{...rest}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
|
@@ -3,15 +3,19 @@ import React from "react";
|
||||
const DropdownMenuItemCustom = ({
|
||||
children,
|
||||
className = "",
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
|
||||
selected ? `dropdown-menu-item--selected` : ``
|
||||
}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
@@ -12,6 +12,7 @@ const DropdownMenuItemLink = ({
|
||||
children,
|
||||
onSelect,
|
||||
className = "",
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
@@ -19,6 +20,7 @@ const DropdownMenuItemLink = ({
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onSelect?: (event: Event) => void;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
@@ -29,7 +31,7 @@ const DropdownMenuItemLink = ({
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
@@ -6,8 +6,13 @@ export const DropdownMenuContentPropsContext = React.createContext<{
|
||||
onSelect?: (event: Event) => void;
|
||||
}>({});
|
||||
|
||||
export const getDropdownMenuItemClassName = (className = "") => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
|
||||
export const getDropdownMenuItemClassName = (
|
||||
className = "",
|
||||
selected = false,
|
||||
) => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
|
||||
selected ? "dropdown-menu-item--selected" : ""
|
||||
}`.trim();
|
||||
};
|
||||
|
||||
export const useHandleDropdownMenuItemClick = (
|
||||
|
@@ -13,7 +13,7 @@ import clsx from "clsx";
|
||||
import { Theme } from "../element/types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
|
||||
const handlerColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||
@@ -1653,3 +1653,22 @@ export const frameToolIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const laserPointerToolIcon = createIcon(
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transform="rotate(90 10 10)"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
|
||||
/>
|
||||
<path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
|
||||
</g>,
|
||||
|
||||
20,
|
||||
);
|
||||
|
@@ -67,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
frame: true,
|
||||
embeddable: true,
|
||||
hand: true,
|
||||
laser: false,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
|
@@ -158,7 +158,7 @@ export const getElementAbsoluteCoords = (
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
/*
|
||||
* for a given element, `getElementLineSegments` returns line segments
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
@@ -674,6 +674,19 @@ export const getCommonBounds = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
export const getDraggedElementsBounds = (
|
||||
elements: ExcalidrawElement[],
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return [
|
||||
minX + dragOffset.x,
|
||||
minY + dragOffset.y,
|
||||
maxX + dragOffset.x,
|
||||
maxY + dragOffset.y,
|
||||
];
|
||||
};
|
||||
|
||||
export const getResizedElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
nextWidth: number,
|
||||
|
@@ -6,23 +6,22 @@ import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import { getGridPoint } from "../math";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
lockDirection: boolean = false,
|
||||
distanceX: number = 0,
|
||||
distanceY: number = 0,
|
||||
offset: { x: number; y: number },
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
snapOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
gridSize: AppState["gridSize"],
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
@@ -44,12 +43,11 @@ export const dragSelectedElements = (
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
distanceY,
|
||||
pointerDownState,
|
||||
element,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
// update coords of bound text only if we're dragging the container directly
|
||||
// (we don't drag the group that it's part of)
|
||||
@@ -69,12 +67,11 @@ export const dragSelectedElements = (
|
||||
(!textElement.frameId || !frames.includes(textElement.frameId))
|
||||
) {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
distanceY,
|
||||
pointerDownState,
|
||||
textElement,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,31 +82,40 @@ export const dragSelectedElements = (
|
||||
};
|
||||
|
||||
const updateElementCoords = (
|
||||
lockDirection: boolean,
|
||||
distanceX: number,
|
||||
distanceY: number,
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
offset: { x: number; y: number },
|
||||
dragOffset: { x: number; y: number },
|
||||
snapOffset: { x: number; y: number },
|
||||
gridSize: AppState["gridSize"],
|
||||
) => {
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (lockDirection) {
|
||||
const lockX = lockDirection && distanceX < distanceY;
|
||||
const lockY = lockDirection && distanceX > distanceY;
|
||||
const original = pointerDownState.originalElements.get(element.id);
|
||||
x = lockX && original ? original.x : element.x + offset.x;
|
||||
y = lockY && original ? original.y : element.y + offset.y;
|
||||
} else {
|
||||
x = element.x + offset.x;
|
||||
y = element.y + offset.y;
|
||||
const originalElement =
|
||||
pointerDownState.originalElements.get(element.id) ?? element;
|
||||
|
||||
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
|
||||
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
|
||||
|
||||
if (snapOffset.x === 0 || snapOffset.y === 0) {
|
||||
const [nextGridX, nextGridY] = getGridPoint(
|
||||
originalElement.x + dragOffset.x,
|
||||
originalElement.y + dragOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (snapOffset.x === 0) {
|
||||
nextX = nextGridX;
|
||||
}
|
||||
|
||||
if (snapOffset.y === 0) {
|
||||
nextY = nextGridY;
|
||||
}
|
||||
}
|
||||
|
||||
mutateElement(element, {
|
||||
x,
|
||||
y,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDragOffsetXY = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
x: number,
|
||||
@@ -133,6 +139,10 @@ export const dragNewElement = (
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
originOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null = null,
|
||||
) => {
|
||||
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||
if (widthAspectRatio) {
|
||||
@@ -173,8 +183,8 @@ export const dragNewElement = (
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
mutateElement(draggingElement, {
|
||||
x: newX,
|
||||
y: newY,
|
||||
x: newX + (originOffset?.x ?? 0),
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
@@ -203,7 +203,6 @@ describe("duplicating multiple elements", () => {
|
||||
);
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
// console.log(arrow);
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
|
||||
).toEqual(
|
||||
|
@@ -41,7 +41,7 @@ import {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
} from "./transformHandles";
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import { AppState, Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineWidth,
|
||||
@@ -79,6 +79,7 @@ export const transformElements = (
|
||||
pointerY: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
appState: AppState,
|
||||
) => {
|
||||
if (selectedElements.length === 1) {
|
||||
const [element] = selectedElements;
|
||||
@@ -466,8 +467,8 @@ export const resizeSingleElement = (
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
eleNewWidth = Math.max(eleNewWidth, minWidth);
|
||||
eleNewHeight = Math.max(eleNewHeight, minHeight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,8 +509,11 @@ export const resizeSingleElement = (
|
||||
}
|
||||
}
|
||||
|
||||
const flipX = eleNewWidth < 0;
|
||||
const flipY = eleNewHeight < 0;
|
||||
|
||||
// Flip horizontally
|
||||
if (eleNewWidth < 0) {
|
||||
if (flipX) {
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
newTopLeft[0] -= Math.abs(newBoundsWidth);
|
||||
}
|
||||
@@ -517,8 +521,9 @@ export const resizeSingleElement = (
|
||||
newTopLeft[0] += Math.abs(newBoundsWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Flip vertically
|
||||
if (eleNewHeight < 0) {
|
||||
if (flipY) {
|
||||
if (transformHandleDirection.includes("s")) {
|
||||
newTopLeft[1] -= Math.abs(newBoundsHeight);
|
||||
}
|
||||
@@ -542,10 +547,20 @@ export const resizeSingleElement = (
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
const linearElementXOffset = stateAtResizeStart.x - newBoundsX1;
|
||||
const linearElementYOffset = stateAtResizeStart.y - newBoundsY1;
|
||||
newOrigin[0] += linearElementXOffset;
|
||||
newOrigin[1] += linearElementYOffset;
|
||||
|
||||
const nextX = newOrigin[0];
|
||||
const nextY = newOrigin[1];
|
||||
|
||||
// Readjust points for linear elements
|
||||
let rescaledElementPointsY;
|
||||
let rescaledPoints;
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
rescaledElementPointsY = rescalePoints(
|
||||
1,
|
||||
@@ -562,16 +577,11 @@ export const resizeSingleElement = (
|
||||
);
|
||||
}
|
||||
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
||||
const resizedElement = {
|
||||
width: Math.abs(eleNewWidth),
|
||||
height: Math.abs(eleNewHeight),
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
points: rescaledPoints,
|
||||
};
|
||||
|
||||
@@ -680,6 +690,10 @@ export const resizeMultipleElements = (
|
||||
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||
targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
||||
);
|
||||
|
||||
// const originalHeight = maxY - minY;
|
||||
// const originalWidth = maxX - minX;
|
||||
|
||||
const direction = transformHandleType;
|
||||
|
||||
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
||||
|
@@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
appState.activeTool.type !== "hand" &&
|
||||
appState.activeTool.type !== "laser"))) ||
|
||||
getSelectedElements(elements, appState).length),
|
||||
);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { GlobalTestState, render, screen } from "../tests/test-utils";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
@@ -41,7 +41,7 @@ describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.elements = [];
|
||||
});
|
||||
|
||||
@@ -243,7 +243,7 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
//@ts-ignore
|
||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
||||
|
||||
@@ -477,7 +477,7 @@ describe("textWysiwyg", () => {
|
||||
const { h } = window;
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.elements = [];
|
||||
|
||||
rectangle = UI.createElement("rectangle", {
|
||||
@@ -957,7 +957,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
85,
|
||||
4.5,
|
||||
4.999999999999986,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -1002,8 +1002,8 @@ describe("textWysiwyg", () => {
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
375,
|
||||
-539,
|
||||
374.99999999999994,
|
||||
-535.0000000000001,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1190,7 +1190,7 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect(rectangle.height).toBe(156);
|
||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
|
||||
mouse.select(rectangle);
|
||||
@@ -1200,9 +1200,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(156);
|
||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||
// cache updated again
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
|
||||
155,
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when font properties updated", async () => {
|
||||
@@ -1511,7 +1514,7 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
it("should bump the version of labelled arrow when label updated", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
const arrow = UI.createElement("arrow", {
|
||||
width: 300,
|
||||
height: 0,
|
||||
|
@@ -177,7 +177,7 @@ describe("adding elements to frames", () => {
|
||||
expectEqualIds([rect2, frame]);
|
||||
});
|
||||
|
||||
it("should add elements", async () => {
|
||||
it.skip("should add elements", async () => {
|
||||
h.elements = [rect2, rect3, frame];
|
||||
|
||||
func(frame, rect2);
|
||||
@@ -188,7 +188,7 @@ describe("adding elements to frames", () => {
|
||||
expectEqualIds([rect3, rect2, frame]);
|
||||
});
|
||||
|
||||
it("should add elements when there are other other elements in between", async () => {
|
||||
it.skip("should add elements when there are other other elements in between", async () => {
|
||||
h.elements = [rect1, rect2, rect4, rect3, frame];
|
||||
|
||||
func(frame, rect2);
|
||||
@@ -199,7 +199,7 @@ describe("adding elements to frames", () => {
|
||||
expectEqualIds([rect1, rect4, rect3, rect2, frame]);
|
||||
});
|
||||
|
||||
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
h.elements = [rect3, rect4, rect2, rect1, frame];
|
||||
|
||||
func(frame, rect2);
|
||||
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
|
||||
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
|
||||
});
|
||||
|
||||
it("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
|
||||
h.elements = [rect3, rect4, frame, rect2, rect1];
|
||||
|
||||
func(frame, rect2);
|
||||
|
132
src/frame.ts
132
src/frame.ts
@@ -14,7 +14,7 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "./element/textElement";
|
||||
import { arrayToMap, findIndex } from "./utils";
|
||||
import { arrayToMap } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
@@ -457,85 +457,87 @@ export const addElementsToFrame = (
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const _elementsToAdd: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elementsToAdd) {
|
||||
_elementsToAdd.push(element);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
_elementsToAdd.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
|
||||
const allElementsIndex = allElements.reduce(
|
||||
(acc: Record<string, number>, element, index) => {
|
||||
acc[element.id] = index;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
const currTargetFrameChildrenMap = new Map(
|
||||
allElements.reduce(
|
||||
(acc: [ExcalidrawElement["id"], ExcalidrawElement][], element) => {
|
||||
if (element.frameId === frame.id) {
|
||||
acc.push([element.id, element]);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
const frameIndex = allElementsIndex[frame.id];
|
||||
// need to be calculated before the mutation below occurs
|
||||
const leftFrameBoundaryIndex = findIndex(
|
||||
allElements,
|
||||
(e) => e.frameId === frame.id,
|
||||
);
|
||||
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
|
||||
|
||||
const existingFrameChildren = allElements.filter(
|
||||
(element) => element.frameId === frame.id,
|
||||
);
|
||||
|
||||
const addedFrameChildren_left: ExcalidrawElement[] = [];
|
||||
const addedFrameChildren_right: ExcalidrawElement[] = [];
|
||||
const finalElementsToAdd: ExcalidrawElement[] = [];
|
||||
|
||||
// - add bound text elements if not already in the array
|
||||
// - filter out elements that are already in the frame
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
allElements,
|
||||
_elementsToAdd,
|
||||
elementsToAdd,
|
||||
)) {
|
||||
if (element.frameId !== frame.id && !isFrameElement(element)) {
|
||||
if (allElementsIndex[element.id] > frameIndex) {
|
||||
addedFrameChildren_right.push(element);
|
||||
} else {
|
||||
addedFrameChildren_left.push(element);
|
||||
}
|
||||
if (!currTargetFrameChildrenMap.has(element.id)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (
|
||||
boundTextElement &&
|
||||
!suppliedElementsToAddSet.has(boundTextElement.id) &&
|
||||
!currTargetFrameChildrenMap.has(boundTextElement.id)
|
||||
) {
|
||||
finalElementsToAdd.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
|
||||
const frameElement = allElements[frameIndex];
|
||||
const nextFrameChildren = addedFrameChildren_left
|
||||
.concat(existingFrameChildren)
|
||||
.concat(addedFrameChildren_right);
|
||||
const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
|
||||
|
||||
const nextFrameChildrenMap = nextFrameChildren.reduce(
|
||||
(acc: Record<string, boolean>, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const nextElements: ExcalidrawElement[] = [];
|
||||
|
||||
const nextOtherElements_left = allElements
|
||||
.slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
|
||||
.filter((element) => !nextFrameChildrenMap[element.id]);
|
||||
const processedElements = new Set<ExcalidrawElement["id"]>();
|
||||
|
||||
const nextOtherElement_right = allElements
|
||||
.slice(frameIndex + 1)
|
||||
.filter((element) => !nextFrameChildrenMap[element.id]);
|
||||
for (const element of allElements) {
|
||||
if (processedElements.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextElements = nextOtherElements_left
|
||||
.concat(nextFrameChildren)
|
||||
.concat([frameElement])
|
||||
.concat(nextOtherElement_right);
|
||||
processedElements.add(element.id);
|
||||
|
||||
if (
|
||||
finalElementsToAddSet.has(element.id) ||
|
||||
(element.frameId && element.frameId === frame.id)
|
||||
) {
|
||||
// will be added in bulk once we process target frame
|
||||
continue;
|
||||
}
|
||||
|
||||
// target frame
|
||||
if (element.id === frame.id) {
|
||||
const currFrameChildren = getFrameElements(allElements, frame.id);
|
||||
currFrameChildren.forEach((child) => {
|
||||
processedElements.add(child.id);
|
||||
});
|
||||
// console.log(currFrameChildren, finalElementsToAdd, element);
|
||||
nextElements.push(...currFrameChildren, ...finalElementsToAdd, element);
|
||||
continue;
|
||||
}
|
||||
|
||||
// console.log("(2)", element.frameId);
|
||||
nextElements.push(element);
|
||||
}
|
||||
|
||||
for (const element of finalElementsToAdd) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
7
src/hooks/useStable.ts
Normal file
7
src/hooks/useStable.ts
Normal 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;
|
||||
};
|
@@ -1,9 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./excalidraw-app";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import "./excalidraw-app/sentry";
|
||||
import "../excalidraw-app/sentry";
|
||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
|
@@ -21,6 +21,7 @@ export const CODES = {
|
||||
V: "KeyV",
|
||||
Z: "KeyZ",
|
||||
R: "KeyR",
|
||||
S: "KeyS",
|
||||
} as const;
|
||||
|
||||
export const KEYS = {
|
||||
|
@@ -164,6 +164,7 @@
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode",
|
||||
"zenMode": "Zen mode",
|
||||
"objectsSnapMode": "Snap to objects",
|
||||
"exitZenMode": "Exit zen mode",
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
@@ -235,6 +236,7 @@
|
||||
"eraser": "Eraser",
|
||||
"frame": "Frame tool",
|
||||
"embeddable": "Web Embed",
|
||||
"laser": "Laser pointer",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools"
|
||||
},
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { rotate } from "./math";
|
||||
import { rangeIntersection, rangesOverlap, rotate } from "./math";
|
||||
|
||||
describe("rotate", () => {
|
||||
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
|
||||
@@ -13,3 +13,43 @@ describe("rotate", () => {
|
||||
expect(res2).toEqual([x1, x2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("range overlap", () => {
|
||||
it("should overlap when range a contains range b", () => {
|
||||
expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
|
||||
expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
|
||||
expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
|
||||
expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
|
||||
});
|
||||
|
||||
it("should overlap when range b contains range a", () => {
|
||||
expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
|
||||
expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
|
||||
expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
|
||||
});
|
||||
|
||||
it("should overlap when range a and b intersect", () => {
|
||||
expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("range intersection", () => {
|
||||
it("should intersect completely with itself", () => {
|
||||
expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
it("should intersect irrespective of order", () => {
|
||||
expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
|
||||
expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
|
||||
expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
|
||||
expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it("should intersect at the edge", () => {
|
||||
expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
|
||||
});
|
||||
|
||||
it("should not intersect", () => {
|
||||
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
33
src/math.ts
33
src/math.ts
@@ -472,3 +472,36 @@ export const isRightAngle = (angle: number) => {
|
||||
// angle, which we can check with modulo after rounding.
|
||||
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
|
||||
};
|
||||
|
||||
// Given two ranges, return if the two ranges overlap with each other
|
||||
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
|
||||
export const rangesOverlap = (
|
||||
[a0, a1]: [number, number],
|
||||
[b0, b1]: [number, number],
|
||||
) => {
|
||||
if (a0 <= b0) {
|
||||
return a1 >= b0;
|
||||
}
|
||||
|
||||
if (a0 >= b0) {
|
||||
return b1 >= a0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Given two ranges,return ther intersection of the two ranges if any
|
||||
// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
|
||||
export const rangeIntersection = (
|
||||
rangeA: [number, number],
|
||||
rangeB: [number, number],
|
||||
): [number, number] | null => {
|
||||
const rangeStart = Math.max(rangeA[0], rangeB[0]);
|
||||
const rangeEnd = Math.min(rangeA[1], rangeB[1]);
|
||||
|
||||
if (rangeStart <= rangeEnd) {
|
||||
return [rangeStart, rangeEnd];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
@@ -11,6 +11,28 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [7078](https://github.com/excalidraw/excalidraw/pull/7078)
|
||||
|
||||
## 0.16.1 (2023-09-21)
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
**_This section lists the updates made to the excalidraw library and will not affect the integration._**
|
||||
|
||||
### Fixes
|
||||
|
||||
- More eye-droper fixes [#7019](https://github.com/excalidraw/excalidraw/pull/7019)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move excalidraw-app outside src [#6987](https://github.com/excalidraw/excalidraw/pull/6987)
|
||||
|
||||
---
|
||||
|
||||
## 0.16.0 (2023-09-19)
|
||||
|
||||
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/excalidraw",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.1",
|
||||
"main": "main.js",
|
||||
"types": "types/packages/excalidraw/index.d.ts",
|
||||
"files": [
|
||||
|
@@ -22,5 +22,12 @@ const polyfill = () => {
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function (...nodes) {
|
||||
this.innerHTML = "";
|
||||
this.append(...nodes);
|
||||
};
|
||||
}
|
||||
};
|
||||
export default polyfill;
|
||||
|
@@ -67,6 +67,7 @@ import {
|
||||
EXTERNAL_LINK_IMG,
|
||||
getLinkHandleFromCoords,
|
||||
} from "../element/Hyperlink";
|
||||
import { renderSnaps } from "./renderSnaps";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
isFrameElement,
|
||||
@@ -720,6 +721,8 @@ const _renderInteractiveScene = ({
|
||||
context.restore();
|
||||
}
|
||||
|
||||
renderSnaps(context, appState);
|
||||
|
||||
// Reset zoom
|
||||
context.restore();
|
||||
|
||||
|
189
src/renderer/renderSnaps.ts
Normal file
189
src/renderer/renderSnaps.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||
import { InteractiveCanvasAppState, Point } from "../types";
|
||||
|
||||
const SNAP_COLOR_LIGHT = "#ff6b6b";
|
||||
const SNAP_COLOR_DARK = "#ff0000";
|
||||
const SNAP_WIDTH = 1;
|
||||
const SNAP_CROSS_SIZE = 2;
|
||||
|
||||
export const renderSnaps = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (!appState.snapLines.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// in dark mode, we need to adjust the color to account for color inversion.
|
||||
// Don't change if zen mode, because we draw only crosses, we want the
|
||||
// colors to be more visible
|
||||
const snapColor =
|
||||
appState.theme === "light" || appState.zenModeEnabled
|
||||
? SNAP_COLOR_LIGHT
|
||||
: SNAP_COLOR_DARK;
|
||||
// in zen mode make the cross more visible since we don't draw the lines
|
||||
const snapWidth =
|
||||
(appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) /
|
||||
appState.zoom.value;
|
||||
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
for (const snapLine of appState.snapLines) {
|
||||
if (snapLine.type === "pointer") {
|
||||
context.lineWidth = snapWidth;
|
||||
context.strokeStyle = snapColor;
|
||||
|
||||
drawPointerSnapLine(snapLine, context, appState);
|
||||
} else if (snapLine.type === "gap") {
|
||||
context.lineWidth = snapWidth;
|
||||
context.strokeStyle = snapColor;
|
||||
|
||||
drawGapLine(
|
||||
snapLine.points[0],
|
||||
snapLine.points[1],
|
||||
snapLine.direction,
|
||||
appState,
|
||||
context,
|
||||
);
|
||||
} else if (snapLine.type === "points") {
|
||||
context.lineWidth = snapWidth;
|
||||
context.strokeStyle = snapColor;
|
||||
drawPointsSnapLine(snapLine, context, appState);
|
||||
}
|
||||
}
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawPointsSnapLine = (
|
||||
pointSnapLine: PointSnapLine,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (!appState.zenModeEnabled) {
|
||||
const firstPoint = pointSnapLine.points[0];
|
||||
const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1];
|
||||
|
||||
drawLine(firstPoint, lastPoint, context);
|
||||
}
|
||||
|
||||
for (const point of pointSnapLine.points) {
|
||||
drawCross(point, appState, context);
|
||||
}
|
||||
};
|
||||
|
||||
const drawPointerSnapLine = (
|
||||
pointerSnapLine: PointerSnapLine,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
drawCross(pointerSnapLine.points[0], appState, context);
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context);
|
||||
}
|
||||
};
|
||||
|
||||
const drawCross = (
|
||||
[x, y]: Point,
|
||||
appState: InteractiveCanvasAppState,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => {
|
||||
context.save();
|
||||
const size =
|
||||
(appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) /
|
||||
appState.zoom.value;
|
||||
context.beginPath();
|
||||
|
||||
context.moveTo(x - size, y - size);
|
||||
context.lineTo(x + size, y + size);
|
||||
|
||||
context.moveTo(x + size, y - size);
|
||||
context.lineTo(x - size, y + size);
|
||||
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawLine = (
|
||||
from: Point,
|
||||
to: Point,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.lineTo(...from);
|
||||
context.lineTo(...to);
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
const drawGapLine = (
|
||||
from: Point,
|
||||
to: Point,
|
||||
direction: "horizontal" | "vertical",
|
||||
appState: InteractiveCanvasAppState,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => {
|
||||
// a horizontal gap snap line
|
||||
// |–––––––||–––––––|
|
||||
// ^ ^ ^ ^
|
||||
// \ \ \ \
|
||||
// (1) (2) (3) (4)
|
||||
|
||||
const FULL = 8 / appState.zoom.value;
|
||||
const HALF = FULL / 2;
|
||||
const QUARTER = FULL / 4;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
const halfPoint = [(from[0] + to[0]) / 2, from[1]];
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
[halfPoint[0] - QUARTER, halfPoint[1] - HALF],
|
||||
[halfPoint[0] - QUARTER, halfPoint[1] + HALF],
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
[halfPoint[0] + QUARTER, halfPoint[1] - HALF],
|
||||
[halfPoint[0] + QUARTER, halfPoint[1] + HALF],
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
}
|
||||
} else {
|
||||
const halfPoint = [from[0], (from[1] + to[1]) / 2];
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
[halfPoint[0] - HALF, halfPoint[1] - QUARTER],
|
||||
[halfPoint[0] + HALF, halfPoint[1] - QUARTER],
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
[halfPoint[0] - HALF, halfPoint[1] + QUARTER],
|
||||
[halfPoint[0] + HALF, halfPoint[1] + QUARTER],
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
}
|
||||
}
|
||||
};
|
@@ -11,6 +11,7 @@ import {
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
||||
@@ -89,6 +90,26 @@ export const getElementsWithinSelection = (
|
||||
return elementsInSelection;
|
||||
};
|
||||
|
||||
export const getVisibleAndNonSelectedElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElementsSet = new Set(
|
||||
selectedElements.map((element) => element.id),
|
||||
);
|
||||
return elements.filter((element) => {
|
||||
const isVisible = isElementInViewport(
|
||||
element,
|
||||
appState.width,
|
||||
appState.height,
|
||||
appState,
|
||||
);
|
||||
|
||||
return !selectedElementsSet.has(element.id) && isVisible;
|
||||
});
|
||||
};
|
||||
|
||||
// FIXME move this into the editor instance to keep utility methods stateless
|
||||
export const isSomeElementSelected = (function () {
|
||||
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
|
||||
|
1361
src/snapping.ts
Normal file
1361
src/snapping.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import * as Renderer from "../renderer/renderScene";
|
||||
import { reseed } from "../random";
|
||||
import { render, queryByTestId } from "../tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
|
||||
@@ -35,7 +35,7 @@ describe("Test <App/>", () => {
|
||||
};
|
||||
};
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw />);
|
||||
expect(
|
||||
queryByTestId(
|
||||
document.querySelector(".excalidraw-modal-container")!,
|
50
src/tests/__snapshots__/App.test.tsx.snap
Normal file
50
src/tests/__snapshots__/App.test.tsx.snap
Normal 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>
|
||||
`;
|
@@ -331,12 +331,17 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -363,6 +368,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -524,12 +530,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -553,6 +561,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -666,7 +675,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `7`;
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `6`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -723,12 +732,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -752,6 +763,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1039,7 +1051,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `13`;
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `12`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1096,12 +1108,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1125,6 +1139,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1412,7 +1427,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `13`;
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `12`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1469,12 +1484,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1498,6 +1515,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1611,7 +1629,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `7`;
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `6`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1668,12 +1686,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1695,6 +1715,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1847,7 +1868,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `8`;
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `7`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1904,12 +1925,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1933,6 +1956,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -2148,7 +2172,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `8`;
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `7`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -2205,12 +2229,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -2239,6 +2265,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -2537,7 +2564,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `13`;
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `12`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -2594,12 +2621,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -2623,6 +2652,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -3416,7 +3446,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `20`;
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `19`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -3473,12 +3503,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -3502,6 +3534,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -3789,7 +3822,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `12`;
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `11`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -3846,12 +3879,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -3875,6 +3910,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -4162,7 +4198,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `12`;
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `11`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -4219,12 +4255,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -4251,6 +4289,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -4618,7 +4657,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `14`;
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `13`;
|
||||
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -4951,12 +4990,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -4983,6 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -5198,7 +5240,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `13`;
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `12`;
|
||||
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -5531,12 +5573,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -5565,6 +5609,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -5863,7 +5908,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `14`;
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `13`;
|
||||
|
||||
exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -5950,6 +5995,19 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
},
|
||||
"viewMode": true,
|
||||
},
|
||||
{
|
||||
"checked": [Function],
|
||||
"contextItemLabel": "buttons.objectsSnapMode",
|
||||
"keyTest": [Function],
|
||||
"name": "objectsSnapMode",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "canvas",
|
||||
"predicate": [Function],
|
||||
},
|
||||
"viewMode": true,
|
||||
},
|
||||
{
|
||||
"checked": [Function],
|
||||
"contextItemLabel": "buttons.zenMode",
|
||||
@@ -6035,12 +6093,17 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -6062,6 +6125,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -6431,12 +6495,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -6460,6 +6526,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -6805,12 +6872,17 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -6834,6 +6906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -7031,6 +7104,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] nu
|
||||
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of elements 2`] = `2`;
|
||||
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `7`;
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `6`;
|
||||
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`;
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { CODES } from "../keys";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
screen,
|
||||
togglePopover,
|
||||
} from "../tests/test-utils";
|
||||
import { copiedStyles } from "./actionStyles";
|
||||
import { copiedStyles } from "../actions/actionStyles";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -17,7 +17,7 @@ const mouse = new Pointer("mouse");
|
||||
|
||||
describe("actionStyles", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
@@ -1,6 +1,6 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { render } from "./test-utils";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../../src/packages/excalidraw/index";
|
||||
import { defaultLang, setLanguage } from "../i18n";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
@@ -60,7 +60,7 @@ describe("aligning", () => {
|
||||
mouse.reset();
|
||||
|
||||
await setLanguage(defaultLang);
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("aligns two objects correctly to the top", () => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { queryByTestId, render, waitFor } from "./test-utils";
|
||||
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { API } from "./helpers/api";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
@@ -14,14 +14,17 @@ describe("appState", () => {
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const exportBackground = !defaultAppState.exportBackground;
|
||||
|
||||
await render(<ExcalidrawApp />, {
|
||||
localStorageData: {
|
||||
appState: {
|
||||
exportBackground,
|
||||
viewBackgroundColor: "#F00",
|
||||
},
|
||||
},
|
||||
});
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
exportBackground,
|
||||
viewBackgroundColor: "#F00",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.exportBackground).toBe(exportBackground);
|
||||
@@ -53,13 +56,15 @@ describe("appState", () => {
|
||||
});
|
||||
|
||||
it("changing fontSize with text tool selected (no element created yet)", async () => {
|
||||
const { container } = await render(<ExcalidrawApp />, {
|
||||
localStorageData: {
|
||||
appState: {
|
||||
currentItemFontSize: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
currentItemFontSize: 30,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render } from "./test-utils";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../../src/packages/excalidraw/index";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { API } from "./helpers/api";
|
||||
@@ -12,7 +12,7 @@ const mouse = new Pointer("mouse");
|
||||
|
||||
describe("element binding", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("should create valid binding if duplicate start/end points", async () => {
|
||||
|
@@ -7,7 +7,7 @@ import {
|
||||
createPasteEvent,
|
||||
} from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { KEYS } from "../keys";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
@@ -79,8 +79,13 @@ beforeEach(async () => {
|
||||
|
||||
mouse.reset();
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
h.app.setAppState({ zoom: { value: 1 as NormalizedZoomValue } });
|
||||
await render(
|
||||
<Excalidraw
|
||||
autoFocus={true}
|
||||
handleKeyboardGlobally={true}
|
||||
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
|
||||
/>,
|
||||
);
|
||||
setClipboardText("");
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
@@ -91,7 +96,6 @@ describe("general paste behavior", () => {
|
||||
it("should randomize seed on paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
waitFor,
|
||||
togglePopover,
|
||||
} from "./test-utils";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { reseed } from "../random";
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
@@ -20,7 +20,6 @@ import { ShortcutName } from "../actions/shortcuts";
|
||||
import { copiedStyles } from "../actions/actionStyles";
|
||||
import { API } from "./helpers/api";
|
||||
import { setDateTimeForTests } from "../utils";
|
||||
import { LibraryItem } from "../types";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const checkpoint = (name: string) => {
|
||||
@@ -56,7 +55,7 @@ describe("contextMenu element", () => {
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -88,6 +87,7 @@ describe("contextMenu element", () => {
|
||||
"gridMode",
|
||||
"zenMode",
|
||||
"viewMode",
|
||||
"objectsSnapMode",
|
||||
"stats",
|
||||
];
|
||||
|
||||
@@ -394,11 +394,9 @@ describe("contextMenu element", () => {
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByText(contextMenu!, "Add to library")!);
|
||||
|
||||
await waitFor(() => {
|
||||
const library = localStorage.getItem("excalidraw-library");
|
||||
expect(library).not.toBeNull();
|
||||
const addedElement = JSON.parse(library!)[0] as LibraryItem;
|
||||
expect(addedElement.elements[0]).toEqual(h.elements[0]);
|
||||
await waitFor(async () => {
|
||||
const libraryItems = await h.app.library.getLatestLibrary();
|
||||
expect(libraryItems[0].elements[0]).toEqual(h.elements[0]);
|
||||
});
|
||||
});
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user