diff --git a/.env.development b/.env.development index 44955884f..95e21ff87 100644 --- a/.env.development +++ b/.env.development @@ -7,9 +7,6 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu # collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room) VITE_APP_WS_SERVER_URL=http://localhost:3002 -# set this only if using the collaboration workflow we use on excalidraw.com -VITE_APP_PORTAL_URL= - VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com diff --git a/.env.production b/.env.production index 26b46a52a..0c715854a 100644 --- a/.env.production +++ b/.env.production @@ -4,16 +4,13 @@ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries -VITE_APP_PORTAL_URL=https://portal.excalidraw.com - VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com -# Fill to set socket server URL used for collaboration. -# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow -VITE_APP_WS_SERVER_URL= +# socket server URL used for collaboration +VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index 4eaeb11f1..5ff5690eb 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -23,5 +23,5 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Auto release run: | - yarn add @actions/core + yarn add @actions/core -W yarn autorelease diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f922f5e75..82f826361 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: - name: Install and lint run: | - yarn install:deps + yarn install yarn test:other yarn test:code yarn test:typecheck diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 02aade54e..5bd3c0d92 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -23,6 +23,6 @@ jobs: - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - build_script: build:umd + build_script: build:esm skip_step: install directory: packages/excalidraw diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 7d77d39f5..7ff40ad5d 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -16,7 +16,7 @@ jobs: with: node-version: "18.x" - name: "Install Deps" - run: yarn install:deps + run: yarn install - name: "Test Coverage" run: yarn test:coverage - name: "Report Coverage" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 124cae26e..2c458a810 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,5 +13,5 @@ jobs: node-version: 18.x - name: Install and test run: | - yarn install:deps + yarn install yarn test:app diff --git a/.gitignore b/.gitignore index d670c78ab..81b63339f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,8 @@ package-lock.json yarn-debug.log* yarn-error.log* packages/excalidraw/types -packages/excalidraw/example/public/bundle.js -packages/excalidraw/example/public/excalidraw-assets-dev -packages/excalidraw/example/public/excalidraw.development.js coverage dev-dist html +examples/**/bundle.* +meta*.json \ No newline at end of file diff --git a/README.md b/README.md index 2a8a3f908..e8cd3b06f 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ We'll be adding these features as drop-in plugins for the npm package in the fut ## Quick start -Install the [Excalidraw npm package](https://www.npmjs.com/package/@excalidraw/excalidraw): +**Note:** following instructions are for installing the Excalidraw [npm package](https://www.npmjs.com/package/@excalidraw/excalidraw) when integrating Excalidraw into your own app. To run the repository locally for development, please refer to our [Development Guide](https://docs.excalidraw.com/docs/introduction/development). ``` npm install react react-dom @excalidraw/excalidraw @@ -97,7 +97,7 @@ or via yarn yarn add react react-dom @excalidraw/excalidraw ``` -Don't forget to check out our [Documentation](https://docs.excalidraw.com)! +Check out our [documentation](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/installation) for more details! ## Contributing diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index c27e96146..ffff19fb0 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -37,7 +37,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git | [setActiveTool](#setactivetool) | `function` | This API can be used to set the active tool | | [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas | | [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas | -| [toggleMenu](#togglemenu) | `function` | Toggles specific menus on/off | +| [toggleSidebar](#toggleSidebar) | `function` | Toggles specific sidebar on/off | | [onChange](#onChange) | `function` | Subscribes to change events | | [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events | | [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events | diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx index 40773a1a2..766c723e4 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx @@ -23,7 +23,7 @@ All `props` are _optional_. | [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | | [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component | | [`name`](#name) | `string` | | Name of the drawing | -| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](#canvasactions) | +| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load | diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx index 5c2c40ccb..9d77e390a 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx @@ -73,9 +73,9 @@ function App() { ## tools -This `prop ` controls the visibility of the tools in the editor. +This `prop` controls the visibility of the tools in the editor. Currently you can control the visibility of `image` tool via this prop. | Prop | Type | Default | Description | | --- | --- | --- | --- | -| image | boolean | true | Decides whether `image` tool should be visible. \ No newline at end of file +| image | boolean | true | Decides whether `image` tool should be visible. diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index 87eb3777d..391b5800b 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -32,15 +32,9 @@ function App() { ### Next.js -Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`. +Since Excalidraw doesn't support `server side rendering` so it should be rendered only on `client`. The way to achieve this in next.js is using `next.js dynamic import`. -Here are two ways on how you can render **Excalidraw** on **Next.js**. - - - -1. Using **Next.js Dynamic** import [Recommended]. - -Since Excalidraw doesn't support server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`. +If you want to only import `Excalidraw` component you can do :point_down: ```jsx showLineNumbers import dynamic from "next/dynamic"; @@ -55,25 +49,88 @@ export default function App() { } ``` -Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2). +However the above component only works for named component exports. If you want to import some util / constant or something else apart from Excalidraw, then this approach will not work. Instead you can write a wrapper over Excalidraw and import the wrapper dynamically. +If you are using `pages router` then importing the wrapper dynamically would work, where as if you are using `app router` then you will have to also add `useClient` directive on top of the file in addition to dynamically importing the wrapper as shown :point_down: -2. Importing Excalidraw once **client** is rendered. + + -```jsx showLineNumbers -import { useState, useEffect } from "react"; -export default function App() { - const [Excalidraw, setExcalidraw] = useState(null); - useEffect(() => { - import("@excalidraw/excalidraw").then((comp) => - setExcalidraw(comp.Excalidraw), + ```jsx showLineNumbers + "use client"; + import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw"; + + import "@excalidraw/excalidraw/index.css"; + + const ExcalidrawWrapper: React.FC = () => { + console.info(convertToExcalidrawElements([{ + type: "rectangle", + id: "rect-1", + width: 186.47265625, + height: 141.9765625, + },])); + return ( +
+ +
); - }, []); - return <>{Excalidraw && }; -} -``` + }; + export default ExcalidrawWrapper; + ``` + +
+ + + + ```jsx showLineNumbers + import dynamic from "next/dynamic"; + + // Since client components get prerenderd on server as well hence importing + // the excalidraw stuff dynamically with ssr false + + const ExcalidrawWrapper = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, + ); + + export default function Page() { + return ( + + ); + } + ``` + + + + + ```jsx showLineNumbers + import dynamic from "next/dynamic"; + + // Since client components get prerenderd on server as well hence importing + // the excalidraw stuff dynamically with ssr false + + const ExcalidrawWrapper = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, + ); + + export default function Page() { + return ( + + ); + } + ``` + + +
+ + +Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/excalidraw/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs-gh6smrdnq-excalidraw.vercel.app/). -Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d) The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm) diff --git a/packages/excalidraw/example/App.scss b/examples/excalidraw/components/App.scss similarity index 83% rename from packages/excalidraw/example/App.scss rename to examples/excalidraw/components/App.scss index 7f37540d8..e41a77ccc 100644 --- a/packages/excalidraw/example/App.scss +++ b/examples/excalidraw/components/App.scss @@ -15,14 +15,23 @@ border-radius: 50%; } } + .app-title { + margin-block-start: 0.83em; + margin-block-end: 0.83em; + } } -.button-wrapper button { - z-index: 1; - height: 40px; - max-width: 200px; - margin: 10px; - padding: 5px; +.button-wrapper { + input[type="checkbox"] { + margin: 5px; + } + button { + z-index: 1; + height: 40px; + max-width: 200px; + margin: 10px; + padding: 5px; + } } .excalidraw .App-menu_top .buttonList { diff --git a/packages/excalidraw/example/App.tsx b/examples/excalidraw/components/App.tsx similarity index 83% rename from packages/excalidraw/example/App.tsx rename to examples/excalidraw/components/App.tsx index 15faede6a..eea0da6ca 100644 --- a/packages/excalidraw/example/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -1,23 +1,31 @@ -import { useEffect, useState, useRef, useCallback } from "react"; - +import React, { + useEffect, + useState, + useRef, + useCallback, + Children, + cloneElement, +} from "react"; import ExampleSidebar from "./sidebar/ExampleSidebar"; -import type * as TExcalidraw from "../index"; +import type * as TExcalidraw from "@excalidraw/excalidraw"; -import "./App.scss"; -import initialData from "./initialData"; import { nanoid } from "nanoid"; + import { resolvablePromise, ResolvablePromise, + distance2d, + fileOpen, withBatchedUpdates, withBatchedUpdatesThrottled, } from "../utils"; -import { EVENT, ROUNDNESS } from "../constants"; -import { distance2d } from "../math"; -import { fileOpen } from "../data/filesystem"; -import { loadSceneOrLibraryFromBlob } from "../../utils"; -import { + +import CustomFooter from "./CustomFooter"; +import MobileFooter from "./MobileFooter"; +import initialData from "../initialData"; + +import type { AppState, BinaryFileData, ExcalidrawImperativeAPI, @@ -25,18 +33,14 @@ import { Gesture, LibraryItems, PointerDownState as ExcalidrawPointerDownState, -} from "../types"; -import { NonDeletedExcalidrawElement, Theme } from "../element/types"; -import { ImportedLibraryData } from "../data/types"; -import CustomFooter from "./CustomFooter"; -import MobileFooter from "./MobileFooter"; -import { KEYS } from "../keys"; +} from "@excalidraw/excalidraw/dist/excalidraw/types"; +import type { + NonDeletedExcalidrawElement, + Theme, +} from "@excalidraw/excalidraw/dist/excalidraw/element/types"; +import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types"; -declare global { - interface Window { - ExcalidrawLib: typeof TExcalidraw; - } -} +import "./App.scss"; type Comment = { x: number; @@ -57,29 +61,6 @@ type PointerDownState = { }; }; -// This is so that we use the bundled excalidraw.development.js file instead -// of the actual source code -const { - exportToCanvas, - exportToSvg, - exportToBlob, - exportToClipboard, - Excalidraw, - useHandleLibrary, - MIME_TYPES, - sceneCoordsToViewportCoords, - viewportCoordsToSceneCoords, - restoreElements, - Sidebar, - Footer, - WelcomeScreen, - MainMenu, - LiveCollaborationTrigger, - convertToExcalidrawElements, - TTDDialog, - TTDDialogTrigger, -} = window.ExcalidrawLib; - const COMMENT_ICON_DIMENSION = 32; const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_WIDTH = 150; @@ -88,9 +69,38 @@ export interface AppProps { appTitle: string; useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; customArgs?: any[]; + children: React.ReactNode; + excalidrawLib: typeof TExcalidraw; } -export default function App({ appTitle, useCustom, customArgs }: AppProps) { +export default function App({ + appTitle, + useCustom, + customArgs, + children, + excalidrawLib, +}: AppProps) { + const { + exportToCanvas, + exportToSvg, + exportToBlob, + exportToClipboard, + useHandleLibrary, + MIME_TYPES, + sceneCoordsToViewportCoords, + viewportCoordsToSceneCoords, + restoreElements, + Sidebar, + Footer, + WelcomeScreen, + MainMenu, + LiveCollaborationTrigger, + convertToExcalidrawElements, + TTDDialog, + TTDDialogTrigger, + ROUNDNESS, + loadSceneOrLibraryFromBlob, + } = excalidrawLib; const appRef = useRef(null); const [viewModeEnabled, setViewModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false); @@ -152,8 +162,105 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { }; }; fetchData(); - }, [excalidrawAPI]); + }, [excalidrawAPI, convertToExcalidrawElements, MIME_TYPES]); + const renderExcalidraw = (children: React.ReactNode) => { + const Excalidraw: any = Children.toArray(children).find( + (child) => + React.isValidElement(child) && + typeof child.type !== "string" && + //@ts-ignore + child.type.displayName === "Excalidraw", + ); + if (!Excalidraw) { + return; + } + const newElement = cloneElement( + Excalidraw, + { + excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api), + initialData: initialStatePromiseRef.current.promise, + onChange: ( + elements: NonDeletedExcalidrawElement[], + state: AppState, + ) => { + console.info("Elements :", elements, "State : ", state); + }, + onPointerUpdate: (payload: { + pointer: { x: number; y: number }; + button: "down" | "up"; + pointersMap: Gesture["pointers"]; + }) => setPointerData(payload), + viewModeEnabled, + zenModeEnabled, + gridModeEnabled, + theme, + name: "Custom name of drawing", + UIOptions: { + canvasActions: { + loadScene: false, + }, + tools: { image: !disableImageTool }, + }, + renderTopRightUI, + onLinkOpen, + onPointerDown, + onScrollChange: rerenderCommentIcons, + validateEmbeddable: true, + }, + <> + {excalidrawAPI && ( + + )} + + + + + Tab one! + Tab two! + + One + Two + + + + + Toggle Custom Sidebar + + {renderMenu()} + {excalidrawAPI && ( + 😀}> + Text to diagram + + )} + { + console.info("submit"); + // sleep for 2s + await new Promise((resolve) => setTimeout(resolve, 2000)); + throw new Error("error, go away now"); + // return "dummy"; + }} + /> + , + ); + return newElement; + }; const renderTopRightUI = (isMobile: boolean) => { return ( <> @@ -337,8 +444,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { pointerDownState: PointerDownState, ) => { return withBatchedUpdates((event) => { - window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove); - window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp); + window.removeEventListener("pointermove", pointerDownState.onMove); + window.removeEventListener("pointerup", pointerDownState.onUp); excalidrawAPI?.setActiveTool({ type: "selection" }); const distance = distance2d( pointerDownState.x, @@ -402,8 +509,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { onPointerMoveFromPointerDownHandler(pointerDownState); const onPointerUp = onPointerUpFromPointerDownHandler(pointerDownState); - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.addEventListener(EVENT.POINTER_UP, onPointerUp); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); pointerDownState.onMove = onPointerMove; pointerDownState.onUp = onPointerUp; @@ -495,7 +602,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { }} onBlur={saveComment} onKeyDown={(event) => { - if (!event.shiftKey && event.key === KEYS.ENTER) { + if (!event.shiftKey && event.key === "Enter") { event.preventDefault(); saveComment(); } @@ -528,7 +635,12 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { - {excalidrawAPI && } + {excalidrawAPI && ( + + )} ); }; @@ -677,83 +789,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
- - setExcalidrawAPI(api) - } - initialData={initialStatePromiseRef.current.promise} - onChange={(elements, state) => { - // console.info("Elements :", elements, "State : ", state); - }} - onPointerUpdate={(payload: { - pointer: { x: number; y: number }; - button: "down" | "up"; - pointersMap: Gesture["pointers"]; - }) => setPointerData(payload)} - viewModeEnabled={viewModeEnabled} - zenModeEnabled={zenModeEnabled} - gridModeEnabled={gridModeEnabled} - theme={theme} - name="Custom name of drawing" - UIOptions={{ - canvasActions: { - loadScene: false, - }, - tools: { image: !disableImageTool }, - }} - renderTopRightUI={renderTopRightUI} - onLinkOpen={onLinkOpen} - onPointerDown={onPointerDown} - onScrollChange={rerenderCommentIcons} - // allow all urls - validateEmbeddable={true} - > - {excalidrawAPI && ( -
- -
- )} - - - - - Tab one! - Tab two! - - One - Two - - - - - Toggle Custom Sidebar - - {renderMenu()} - {excalidrawAPI && ( - 😀}> - Text to diagram - - )} - { - console.info("submit"); - // sleep for 2s - await new Promise((resolve) => setTimeout(resolve, 2000)); - throw new Error("error, go away now"); - // return "dummy"; - }} - /> -
+ {renderExcalidraw(children)} {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {comment && renderComment()}
diff --git a/packages/excalidraw/example/CustomFooter.tsx b/examples/excalidraw/components/CustomFooter.tsx similarity index 78% rename from packages/excalidraw/example/CustomFooter.tsx rename to examples/excalidraw/components/CustomFooter.tsx index ae36f766b..30d51ecf0 100644 --- a/packages/excalidraw/example/CustomFooter.tsx +++ b/examples/excalidraw/components/CustomFooter.tsx @@ -1,6 +1,5 @@ -import { ExcalidrawImperativeAPI } from "../types"; -import { MIME_TYPES } from "../entry"; -import { Button } from "../components/Button"; +import type * as TExcalidraw from "@excalidraw/excalidraw"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types"; const COMMENT_SVG = ( ); + const CustomFooter = ({ excalidrawAPI, + excalidrawLib, }: { excalidrawAPI: ExcalidrawImperativeAPI; + excalidrawLib: typeof TExcalidraw; }) => { + const { Button, MIME_TYPES } = excalidrawLib; + return ( <> - - + ); }; diff --git a/examples/excalidraw/components/MobileFooter.tsx b/examples/excalidraw/components/MobileFooter.tsx new file mode 100644 index 000000000..7ab62b918 --- /dev/null +++ b/examples/excalidraw/components/MobileFooter.tsx @@ -0,0 +1,27 @@ +import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types"; +import CustomFooter from "./CustomFooter"; +import type * as TExcalidraw from "@excalidraw/excalidraw"; + +const MobileFooter = ({ + excalidrawAPI, + excalidrawLib, +}: { + excalidrawAPI: ExcalidrawImperativeAPI; + excalidrawLib: typeof TExcalidraw; +}) => { + const { useDevice, Footer } = excalidrawLib; + + const device = useDevice(); + if (device.editor.isMobile) { + return ( +
+ +
+ ); + } + return null; +}; +export default MobileFooter; diff --git a/packages/excalidraw/example/sidebar/ExampleSidebar.scss b/examples/excalidraw/components/sidebar/ExampleSidebar.scss similarity index 100% rename from packages/excalidraw/example/sidebar/ExampleSidebar.scss rename to examples/excalidraw/components/sidebar/ExampleSidebar.scss diff --git a/packages/excalidraw/example/sidebar/ExampleSidebar.tsx b/examples/excalidraw/components/sidebar/ExampleSidebar.tsx similarity index 94% rename from packages/excalidraw/example/sidebar/ExampleSidebar.tsx rename to examples/excalidraw/components/sidebar/ExampleSidebar.tsx index 4c51ecdc2..8b475f16f 100644 --- a/packages/excalidraw/example/sidebar/ExampleSidebar.tsx +++ b/examples/excalidraw/components/sidebar/ExampleSidebar.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; +import { useState } from "react"; import "./ExampleSidebar.scss"; + export default function Sidebar({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(false); diff --git a/packages/excalidraw/example/initialData.tsx b/examples/excalidraw/initialData.tsx similarity index 99% rename from packages/excalidraw/example/initialData.tsx rename to examples/excalidraw/initialData.tsx index 609c6d41b..3cb5e7af4 100644 --- a/packages/excalidraw/example/initialData.tsx +++ b/examples/excalidraw/initialData.tsx @@ -1,5 +1,5 @@ -import { ExcalidrawElementSkeleton } from "../data/transform"; -import { FileId } from "../element/types"; +import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform"; +import type { FileId } from "@excalidraw/excalidraw/element/types"; const elements: ExcalidrawElementSkeleton[] = [ { diff --git a/examples/excalidraw/package.json b/examples/excalidraw/package.json new file mode 100644 index 000000000..fe48d5532 --- /dev/null +++ b/examples/excalidraw/package.json @@ -0,0 +1,13 @@ +{ + "name": "examples", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "@excalidraw/excalidraw": "*" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/examples/excalidraw/tsconfig.json b/examples/excalidraw/tsconfig.json new file mode 100644 index 000000000..41716a7dd --- /dev/null +++ b/examples/excalidraw/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig" +} diff --git a/examples/excalidraw/utils.ts b/examples/excalidraw/utils.ts new file mode 100644 index 000000000..822be29b7 --- /dev/null +++ b/examples/excalidraw/utils.ts @@ -0,0 +1,146 @@ +import { unstable_batchedUpdates } from "react-dom"; +import { fileOpen as _fileOpen } from "browser-fs-access"; +import type { MIME_TYPES } from "@excalidraw/excalidraw"; +import { AbortError } from "../../packages/excalidraw/errors"; + +type FILE_EXTENSION = Exclude; + +const INPUT_CHANGE_INTERVAL_MS = 500; + +export type ResolvablePromise = Promise & { + resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; + reject: (error: Error) => void; +}; +export const resolvablePromise = () => { + let resolve!: any; + let reject!: any; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + (promise as any).resolve = resolve; + (promise as any).reject = reject; + return promise as ResolvablePromise; +}; + +export const distance2d = (x1: number, y1: number, x2: number, y2: number) => { + const xd = x2 - x1; + const yd = y2 - y1; + return Math.hypot(xd, yd); +}; + +export const fileOpen = (opts: { + extensions?: FILE_EXTENSION[]; + description: string; + multiple?: M; +}): Promise => { + // an unsafe TS hack, alas not much we can do AFAIK + type RetType = M extends false | undefined ? File : File[]; + + const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { + mimeTypes.push(MIME_TYPES[type]); + + return mimeTypes; + }, [] as string[]); + + const extensions = opts.extensions?.reduce((acc, ext) => { + if (ext === "jpg") { + return acc.concat(".jpg", ".jpeg"); + } + return acc.concat(`.${ext}`); + }, [] as string[]); + + return _fileOpen({ + description: opts.description, + extensions, + mimeTypes, + multiple: opts.multiple ?? false, + legacySetup: (resolve, reject, input) => { + const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS); + const focusHandler = () => { + checkForFile(); + document.addEventListener("keyup", scheduleRejection); + document.addEventListener("pointerup", scheduleRejection); + scheduleRejection(); + }; + const checkForFile = () => { + // this hack might not work when expecting multiple files + if (input.files?.length) { + const ret = opts.multiple ? [...input.files] : input.files[0]; + resolve(ret as RetType); + } + }; + requestAnimationFrame(() => { + window.addEventListener("focus", focusHandler); + }); + const interval = window.setInterval(() => { + checkForFile(); + }, INPUT_CHANGE_INTERVAL_MS); + return (rejectPromise) => { + clearInterval(interval); + scheduleRejection.cancel(); + window.removeEventListener("focus", focusHandler); + document.removeEventListener("keyup", scheduleRejection); + document.removeEventListener("pointerup", scheduleRejection); + if (rejectPromise) { + // so that something is shown in console if we need to debug this + console.warn("Opening the file was canceled (legacy-fs)."); + rejectPromise(new AbortError()); + } + }; + }, + }) as Promise; +}; + +export const debounce = ( + fn: (...args: T) => void, + timeout: number, +) => { + let handle = 0; + let lastArgs: T | null = null; + const ret = (...args: T) => { + lastArgs = args; + clearTimeout(handle); + handle = window.setTimeout(() => { + lastArgs = null; + fn(...args); + }, timeout); + }; + ret.flush = () => { + clearTimeout(handle); + if (lastArgs) { + const _lastArgs = lastArgs; + lastArgs = null; + fn(..._lastArgs); + } + }; + ret.cancel = () => { + lastArgs = null; + clearTimeout(handle); + }; + return ret; +}; + +export const withBatchedUpdates = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => + ((event) => { + unstable_batchedUpdates(func as TFunction, event); + }) as TFunction; + +/** + * barches React state updates and throttles the calls to a single call per + * animation frame + */ +export const withBatchedUpdatesThrottled = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => { + // @ts-ignore + return throttleRAF>(((event) => { + unstable_batchedUpdates(func, event); + }) as TFunction); +}; diff --git a/examples/excalidraw/with-nextjs/.gitignore b/examples/excalidraw/with-nextjs/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/examples/excalidraw/with-nextjs/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/excalidraw/with-nextjs/README.md b/examples/excalidraw/with-nextjs/README.md new file mode 100644 index 000000000..9e8d9b96d --- /dev/null +++ b/examples/excalidraw/with-nextjs/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3005) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/examples/excalidraw/with-nextjs/next.config.js b/examples/excalidraw/with-nextjs/next.config.js new file mode 100644 index 000000000..701438ebf --- /dev/null +++ b/examples/excalidraw/with-nextjs/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + distDir: "build", + typescript: { + // The ts config doesn't work with `jsx: preserve" and if updated to `react-jsx` it gets ovewritten by next js throwing ts errors hence I am ignoring build errors until this is fixed. + ignoreBuildErrors: true, + }, + // This is needed as in pages router the code for importing types throws error as its outside next js app + transpilePackages: ["../"], +}; + +module.exports = nextConfig; diff --git a/examples/excalidraw/with-nextjs/package.json b/examples/excalidraw/with-nextjs/package.json new file mode 100644 index 000000000..177952407 --- /dev/null +++ b/examples/excalidraw/with-nextjs/package.json @@ -0,0 +1,25 @@ +{ + "name": "with-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", + "dev": "yarn build:workspace && next dev -p 3005", + "build": "yarn build:workspace && next build", + "start": "next start -p 3006", + "lint": "next lint" + }, + "dependencies": { + "@excalidraw/excalidraw": "*", + "next": "14.1", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "path2d-polyfill": "2.0.1", + "typescript": "^5" + } +} diff --git a/packages/excalidraw/example/public/images/doremon.png b/examples/excalidraw/with-nextjs/public/images/doremon.png similarity index 100% rename from packages/excalidraw/example/public/images/doremon.png rename to examples/excalidraw/with-nextjs/public/images/doremon.png diff --git a/packages/excalidraw/example/public/images/excalibot.png b/examples/excalidraw/with-nextjs/public/images/excalibot.png similarity index 100% rename from packages/excalidraw/example/public/images/excalibot.png rename to examples/excalidraw/with-nextjs/public/images/excalibot.png diff --git a/packages/excalidraw/example/public/images/pika.jpeg b/examples/excalidraw/with-nextjs/public/images/pika.jpeg similarity index 100% rename from packages/excalidraw/example/public/images/pika.jpeg rename to examples/excalidraw/with-nextjs/public/images/pika.jpeg diff --git a/packages/excalidraw/example/public/images/rocket.jpeg b/examples/excalidraw/with-nextjs/public/images/rocket.jpeg similarity index 100% rename from packages/excalidraw/example/public/images/rocket.jpeg rename to examples/excalidraw/with-nextjs/public/images/rocket.jpeg diff --git a/examples/excalidraw/with-nextjs/src/app/favicon.ico b/examples/excalidraw/with-nextjs/src/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/examples/excalidraw/with-nextjs/src/app/favicon.ico differ diff --git a/examples/excalidraw/with-nextjs/src/app/layout.tsx b/examples/excalidraw/with-nextjs/src/app/layout.tsx new file mode 100644 index 000000000..225b6038d --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/examples/excalidraw/with-nextjs/src/app/page.tsx b/examples/excalidraw/with-nextjs/src/app/page.tsx new file mode 100644 index 000000000..bc8c98fcf --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/app/page.tsx @@ -0,0 +1,23 @@ +import dynamic from "next/dynamic"; +import "../common.scss"; + +// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically +// with ssr false +const ExcalidrawWithClientOnly = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, +); + +export default function Page() { + return ( + <> + Switch to Pages router +

App Router

+ + {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} + + + ); +} diff --git a/examples/excalidraw/with-nextjs/src/common.scss b/examples/excalidraw/with-nextjs/src/common.scss new file mode 100644 index 000000000..1a77600a9 --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/common.scss @@ -0,0 +1,15 @@ +* { + box-sizing: border-box; + font-family: sans-serif; +} + +a { + color: #1c7ed6; + font-size: 20px; + text-decoration: none; + font-weight: 550; +} + +.page-title { + text-align: center; +} diff --git a/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx b/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx new file mode 100644 index 000000000..40af9f0cc --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx @@ -0,0 +1,22 @@ +"use client"; +import * as excalidrawLib from "@excalidraw/excalidraw"; +import { Excalidraw } from "@excalidraw/excalidraw"; +import App from "../../components/App"; + +import "@excalidraw/excalidraw/index.css"; + +const ExcalidrawWrapper: React.FC = () => { + return ( + <> + {}} + excalidrawLib={excalidrawLib} + > + + + + ); +}; + +export default ExcalidrawWrapper; diff --git a/examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx b/examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx new file mode 100644 index 000000000..527a346b9 --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx @@ -0,0 +1,22 @@ +import dynamic from "next/dynamic"; +import "../common.scss"; + +// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically +// with ssr false +const Excalidraw = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, +); + +export default function Page() { + return ( + <> + Switch to App router +

Pages Router

+ {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} + + + ); +} diff --git a/examples/excalidraw/with-nextjs/tsconfig.json b/examples/excalidraw/with-nextjs/tsconfig.json new file mode 100644 index 000000000..09ae73d2e --- /dev/null +++ b/examples/excalidraw/with-nextjs/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + }, + "forceConsistentCasingInFileNames": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "build/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/excalidraw/with-nextjs/vercel.json b/examples/excalidraw/with-nextjs/vercel.json new file mode 100644 index 000000000..bd885f4a5 --- /dev/null +++ b/examples/excalidraw/with-nextjs/vercel.json @@ -0,0 +1,3 @@ +{ + "outputDirectory": "build" +} diff --git a/examples/excalidraw/with-nextjs/yarn.lock b/examples/excalidraw/with-nextjs/yarn.lock new file mode 100644 index 000000000..0072235c0 --- /dev/null +++ b/examples/excalidraw/with-nextjs/yarn.lock @@ -0,0 +1,252 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@excalidraw/excalidraw@workspace:^": + version "0.17.2" + resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.2.tgz#9a636a1e6bb3c88c5883347d3a7e75e9cce8ab96" + integrity sha512-7pqUWD8+mPjDhF4XxG3gw4rvE2JGaLW3Vss5UZfTbITPxAtFaGEc1K081bncitnaYhUwN9ENJE0i87QB3poDwQ== + +"@next/env@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.4.tgz#d5cda0c4a862d70ae760e58c0cd96a8899a2e49a" + integrity sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ== + +"@next/swc-darwin-arm64@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz#27b1854c2cd04eb1d5e75081a1a792ad91526618" + integrity sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg== + +"@next/swc-darwin-x64@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz#9940c449e757d0ee50bb9e792d2600cc08a3eb3b" + integrity sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw== + +"@next/swc-linux-arm64-gnu@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz#0eafd27c8587f68ace7b4fa80695711a8434de21" + integrity sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w== + +"@next/swc-linux-arm64-musl@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz#2b0072adb213f36dada5394ea67d6e82069ae7dd" + integrity sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ== + +"@next/swc-linux-x64-gnu@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz#68c67d20ebc8e3f6ced6ff23a4ba2a679dbcec32" + integrity sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A== + +"@next/swc-linux-x64-musl@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz#67cd81b42fb2caf313f7992fcf6d978af55a1247" + integrity sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw== + +"@next/swc-win32-arm64-msvc@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz#be06585906b195d755ceda28f33c633e1443f1a3" + integrity sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w== + +"@next/swc-win32-ia32-msvc@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz#e76cabefa9f2d891599c3d85928475bd8d3f6600" + integrity sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg== + +"@next/swc-win32-x64-msvc@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz#e74892f1a9ccf41d3bf5979ad6d3d77c07b9cba1" + integrity sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A== + +"@swc/helpers@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" + integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== + dependencies: + tslib "^2.4.0" + +"@types/node@^20": + version "20.11.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f" + integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ== + dependencies: + undici-types "~5.26.4" + +"@types/prop-types@*": + version "15.7.11" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" + integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng== + +"@types/react-dom@^18": + version "18.2.18" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd" + integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^18": + version "18.2.47" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.47.tgz#85074b27ab563df01fbc3f68dc64bf7050b0af40" + integrity sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" + integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== + +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +caniuse-lite@^1.0.30001406: + version "1.0.30001576" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz#893be772cf8ee6056d6c1e2d07df365b9ec0a5c4" + integrity sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg== + +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +graceful-fs@^4.1.2, graceful-fs@^4.2.11: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +nanoid@^3.3.6: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +next@14.0.4: + version "14.0.4" + resolved "https://registry.yarnpkg.com/next/-/next-14.0.4.tgz#bf00b6f835b20d10a5057838fa2dfced1d0d84dc" + integrity sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA== + dependencies: + "@next/env" "14.0.4" + "@swc/helpers" "0.5.2" + busboy "1.6.0" + caniuse-lite "^1.0.30001406" + graceful-fs "^4.2.11" + postcss "8.4.31" + styled-jsx "5.1.1" + watchpack "2.4.0" + optionalDependencies: + "@next/swc-darwin-arm64" "14.0.4" + "@next/swc-darwin-x64" "14.0.4" + "@next/swc-linux-arm64-gnu" "14.0.4" + "@next/swc-linux-arm64-musl" "14.0.4" + "@next/swc-linux-x64-gnu" "14.0.4" + "@next/swc-linux-x64-musl" "14.0.4" + "@next/swc-win32-arm64-msvc" "14.0.4" + "@next/swc-win32-ia32-msvc" "14.0.4" + "@next/swc-win32-x64-msvc" "14.0.4" + +path2d-polyfill@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz#24c554a738f42700d6961992bf5f1049672f2391" + integrity sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +react-dom@^18: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.0" + +react@^18: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== + dependencies: + loose-envify "^1.1.0" + +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +styled-jsx@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" + integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw== + dependencies: + client-only "0.0.1" + +tslib@^2.4.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +typescript@^5: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +watchpack@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" diff --git a/packages/excalidraw/example/public/index.html b/examples/excalidraw/with-script-in-browser/index.html similarity index 65% rename from packages/excalidraw/example/public/index.html rename to examples/excalidraw/with-script-in-browser/index.html index 7f4ed2494..a56d7f421 100644 --- a/packages/excalidraw/example/public/index.html +++ b/examples/excalidraw/with-script-in-browser/index.html @@ -12,18 +12,21 @@ +
- - - + + console.log(ExcalidrawLib); + window.ExcalidrawLib = ExcalidrawLib; + + diff --git a/examples/excalidraw/with-script-in-browser/index.tsx b/examples/excalidraw/with-script-in-browser/index.tsx new file mode 100644 index 000000000..e8584d7ca --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/index.tsx @@ -0,0 +1,28 @@ +import App from "../components/App"; +import React, { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import type * as TExcalidraw from "@excalidraw/excalidraw"; + +import "@excalidraw/excalidraw/index.css"; + +declare global { + interface Window { + ExcalidrawLib: typeof TExcalidraw; + } +} + +const rootElement = document.getElementById("root")!; +const root = createRoot(rootElement); +const { Excalidraw } = window.ExcalidrawLib; +root.render( + + {}} + excalidrawLib={window.ExcalidrawLib} + > + + + , +); diff --git a/examples/excalidraw/with-script-in-browser/package.json b/examples/excalidraw/with-script-in-browser/package.json new file mode 100644 index 000000000..d721ac162 --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/package.json @@ -0,0 +1,19 @@ +{ + "name": "with-script-in-browser", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "@excalidraw/excalidraw": "*" + }, + "devDependencies": { + "vite": "5.0.12", + "typescript": "^5" + }, + "scripts": { + "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", + "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", + "build:preview": "yarn build && vite preview --port 5002" + } +} diff --git a/examples/excalidraw/with-script-in-browser/public/images/doremon.png b/examples/excalidraw/with-script-in-browser/public/images/doremon.png new file mode 100644 index 000000000..36208a466 Binary files /dev/null and b/examples/excalidraw/with-script-in-browser/public/images/doremon.png differ diff --git a/examples/excalidraw/with-script-in-browser/public/images/excalibot.png b/examples/excalidraw/with-script-in-browser/public/images/excalibot.png new file mode 100644 index 000000000..7928ec325 Binary files /dev/null and b/examples/excalidraw/with-script-in-browser/public/images/excalibot.png differ diff --git a/examples/excalidraw/with-script-in-browser/public/images/pika.jpeg b/examples/excalidraw/with-script-in-browser/public/images/pika.jpeg new file mode 100644 index 000000000..455ed52a6 Binary files /dev/null and b/examples/excalidraw/with-script-in-browser/public/images/pika.jpeg differ diff --git a/examples/excalidraw/with-script-in-browser/public/images/rocket.jpeg b/examples/excalidraw/with-script-in-browser/public/images/rocket.jpeg new file mode 100644 index 000000000..f17a74bd6 Binary files /dev/null and b/examples/excalidraw/with-script-in-browser/public/images/rocket.jpeg differ diff --git a/packages/excalidraw/vercel.json b/examples/excalidraw/with-script-in-browser/vercel.json similarity index 50% rename from packages/excalidraw/vercel.json rename to examples/excalidraw/with-script-in-browser/vercel.json index a262682b8..139f31ef0 100644 --- a/packages/excalidraw/vercel.json +++ b/examples/excalidraw/with-script-in-browser/vercel.json @@ -1,4 +1,4 @@ { - "outputDirectory": "example/public", + "outputDirectory": "dist", "installCommand": "yarn install" } diff --git a/examples/excalidraw/with-script-in-browser/vite.config.mts b/examples/excalidraw/with-script-in-browser/vite.config.mts new file mode 100644 index 000000000..e2e5e19ac --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/vite.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + port: 3001, + // open the browser + open: true, + }, + publicDir: "public", +}); diff --git a/examples/excalidraw/yarn.lock b/examples/excalidraw/yarn.lock new file mode 100644 index 000000000..1eb584205 --- /dev/null +++ b/examples/excalidraw/yarn.lock @@ -0,0 +1,313 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz#2acd20be6d4f0458bc8c784103495ff24f13b1d3" + integrity sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g== + +"@esbuild/android-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz#b45d000017385c9051a4f03e17078abb935be220" + integrity sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q== + +"@esbuild/android-arm@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.11.tgz#f46f55414e1c3614ac682b29977792131238164c" + integrity sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw== + +"@esbuild/android-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.11.tgz#bfc01e91740b82011ef503c48f548950824922b2" + integrity sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg== + +"@esbuild/darwin-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz#533fb7f5a08c37121d82c66198263dcc1bed29bf" + integrity sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ== + +"@esbuild/darwin-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz#62f3819eff7e4ddc656b7c6815a31cf9a1e7d98e" + integrity sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g== + +"@esbuild/freebsd-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz#d478b4195aa3ca44160272dab85ef8baf4175b4a" + integrity sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA== + +"@esbuild/freebsd-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz#7bdcc1917409178257ca6a1a27fe06e797ec18a2" + integrity sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw== + +"@esbuild/linux-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz#58ad4ff11685fcc735d7ff4ca759ab18fcfe4545" + integrity sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg== + +"@esbuild/linux-arm@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz#ce82246d873b5534d34de1e5c1b33026f35e60e3" + integrity sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q== + +"@esbuild/linux-ia32@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz#cbae1f313209affc74b80f4390c4c35c6ab83fa4" + integrity sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA== + +"@esbuild/linux-loong64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz#5f32aead1c3ec8f4cccdb7ed08b166224d4e9121" + integrity sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg== + +"@esbuild/linux-mips64el@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz#38eecf1cbb8c36a616261de858b3c10d03419af9" + integrity sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg== + +"@esbuild/linux-ppc64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz#9c5725a94e6ec15b93195e5a6afb821628afd912" + integrity sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA== + +"@esbuild/linux-riscv64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz#2dc4486d474a2a62bbe5870522a9a600e2acb916" + integrity sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ== + +"@esbuild/linux-s390x@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz#4ad8567df48f7dd4c71ec5b1753b6f37561a65a8" + integrity sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q== + +"@esbuild/linux-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz#b7390c4d5184f203ebe7ddaedf073df82a658766" + integrity sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA== + +"@esbuild/netbsd-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz#d633c09492a1721377f3bccedb2d821b911e813d" + integrity sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ== + +"@esbuild/openbsd-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz#17388c76e2f01125bf831a68c03a7ffccb65d1a2" + integrity sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw== + +"@esbuild/sunos-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz#e320636f00bb9f4fdf3a80e548cb743370d41767" + integrity sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ== + +"@esbuild/win32-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz#c778b45a496e90b6fc373e2a2bb072f1441fe0ee" + integrity sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ== + +"@esbuild/win32-ia32@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz#481a65fee2e5cce74ec44823e6b09ecedcc5194c" + integrity sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg== + +"@esbuild/win32-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz#a5d300008960bb39677c46bf16f53ec70d8dee04" + integrity sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw== + +"@rollup/rollup-android-arm-eabi@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.5.tgz#b752b6c88a14ccfcbdf3f48c577ccc3a7f0e66b9" + integrity sha512-idWaG8xeSRCfRq9KpRysDHJ/rEHBEXcHuJ82XY0yYFIWnLMjZv9vF/7DOq8djQ2n3Lk6+3qfSH8AqlmHlmi1MA== + +"@rollup/rollup-android-arm64@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.5.tgz#33757c3a448b9ef77b6f6292d8b0ec45c87e9c1a" + integrity sha512-f14d7uhAMtsCGjAYwZGv6TwuS3IFaM4ZnGMUn3aCBgkcHAYErhV1Ad97WzBvS2o0aaDv4mVz+syiN0ElMyfBPg== + +"@rollup/rollup-darwin-arm64@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.5.tgz#5234ba62665a3f443143bc8bcea9df2cc58f55fb" + integrity sha512-ndoXeLx455FffL68OIUrVr89Xu1WLzAG4n65R8roDlCoYiQcGGg6MALvs2Ap9zs7AHg8mpHtMpwC8jBBjZrT/w== + +"@rollup/rollup-darwin-x64@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.5.tgz#981256c054d3247b83313724938d606798a919d1" + integrity sha512-UmElV1OY2m/1KEEqTlIjieKfVwRg0Zwg4PLgNf0s3glAHXBN99KLpw5A5lrSYCa1Kp63czTpVll2MAqbZYIHoA== + +"@rollup/rollup-linux-arm-gnueabihf@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.5.tgz#120678a5a2b3a283a548dbb4d337f9187a793560" + integrity sha512-Q0LcU61v92tQB6ae+udZvOyZ0wfpGojtAKrrpAaIqmJ7+psq4cMIhT/9lfV6UQIpeItnq/2QDROhNLo00lOD1g== + +"@rollup/rollup-linux-arm64-gnu@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz#c99d857e2372ece544b6f60b85058ad259f64114" + integrity sha512-dkRscpM+RrR2Ee3eOQmRWFjmV/payHEOrjyq1VZegRUa5OrZJ2MAxBNs05bZuY0YCtpqETDy1Ix4i/hRqX98cA== + +"@rollup/rollup-linux-arm64-musl@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.5.tgz#3064060f568a5718c2a06858cd6e6d24f2ff8632" + integrity sha512-QaKFVOzzST2xzY4MAmiDmURagWLFh+zZtttuEnuNn19AiZ0T3fhPyjPPGwLNdiDT82ZE91hnfJsUiDwF9DClIQ== + +"@rollup/rollup-linux-riscv64-gnu@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.5.tgz#987d30b5d2b992fff07d055015991a57ff55fbad" + integrity sha512-HeGqmRJuyVg6/X6MpE2ur7GbymBPS8Np0S/vQFHDmocfORT+Zt76qu+69NUoxXzGqVP1pzaY6QIi0FJWLC3OPA== + +"@rollup/rollup-linux-x64-gnu@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz#85946ee4d068bd12197aeeec2c6f679c94978a49" + integrity sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA== + +"@rollup/rollup-linux-x64-musl@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.5.tgz#fe0b20f9749a60eb1df43d20effa96c756ddcbd4" + integrity sha512-ezyFUOwldYpj7AbkwyW9AJ203peub81CaAIVvckdkyH8EvhEIoKzaMFJj0G4qYJ5sw3BpqhFrsCc30t54HV8vg== + +"@rollup/rollup-win32-arm64-msvc@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.5.tgz#422661ef0e16699a234465d15b2c1089ef963b2a" + integrity sha512-aHSsMnUw+0UETB0Hlv7B/ZHOGY5bQdwMKJSzGfDfvyhnpmVxLMGnQPGNE9wgqkLUs3+gbG1Qx02S2LLfJ5GaRQ== + +"@rollup/rollup-win32-ia32-msvc@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.5.tgz#7b73a145891c202fbcc08759248983667a035d85" + integrity sha512-AiqiLkb9KSf7Lj/o1U3SEP9Zn+5NuVKgFdRIZkvd4N0+bYrTOovVd0+LmYCPQGbocT4kvFyK+LXCDiXPBF3fyA== + +"@rollup/rollup-win32-x64-msvc@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.5.tgz#10491ccf4f63c814d4149e0316541476ea603602" + integrity sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ== + +"@types/estree@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +esbuild@^0.19.3: + version "0.19.11" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.11.tgz#4a02dca031e768b5556606e1b468fe72e3325d96" + integrity sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.11" + "@esbuild/android-arm" "0.19.11" + "@esbuild/android-arm64" "0.19.11" + "@esbuild/android-x64" "0.19.11" + "@esbuild/darwin-arm64" "0.19.11" + "@esbuild/darwin-x64" "0.19.11" + "@esbuild/freebsd-arm64" "0.19.11" + "@esbuild/freebsd-x64" "0.19.11" + "@esbuild/linux-arm" "0.19.11" + "@esbuild/linux-arm64" "0.19.11" + "@esbuild/linux-ia32" "0.19.11" + "@esbuild/linux-loong64" "0.19.11" + "@esbuild/linux-mips64el" "0.19.11" + "@esbuild/linux-ppc64" "0.19.11" + "@esbuild/linux-riscv64" "0.19.11" + "@esbuild/linux-s390x" "0.19.11" + "@esbuild/linux-x64" "0.19.11" + "@esbuild/netbsd-x64" "0.19.11" + "@esbuild/openbsd-x64" "0.19.11" + "@esbuild/sunos-x64" "0.19.11" + "@esbuild/win32-arm64" "0.19.11" + "@esbuild/win32-ia32" "0.19.11" + "@esbuild/win32-x64" "0.19.11" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +postcss@^8.4.32: + version "8.4.33" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742" + integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +react-dom@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.0" + +react@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== + dependencies: + loose-envify "^1.1.0" + +rollup@^4.2.0: + version "4.9.5" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.5.tgz#62999462c90f4c8b5d7c38fc7161e63b29101b05" + integrity sha512-E4vQW0H/mbNMw2yLSqJyjtkHY9dslf/p0zuT1xehNRqUTBOFMqEjguDvqhXr7N7r/4ttb2jr4T41d3dncmIgbQ== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.9.5" + "@rollup/rollup-android-arm64" "4.9.5" + "@rollup/rollup-darwin-arm64" "4.9.5" + "@rollup/rollup-darwin-x64" "4.9.5" + "@rollup/rollup-linux-arm-gnueabihf" "4.9.5" + "@rollup/rollup-linux-arm64-gnu" "4.9.5" + "@rollup/rollup-linux-arm64-musl" "4.9.5" + "@rollup/rollup-linux-riscv64-gnu" "4.9.5" + "@rollup/rollup-linux-x64-gnu" "4.9.5" + "@rollup/rollup-linux-x64-musl" "4.9.5" + "@rollup/rollup-win32-arm64-msvc" "4.9.5" + "@rollup/rollup-win32-ia32-msvc" "4.9.5" + "@rollup/rollup-win32-x64-msvc" "4.9.5" + fsevents "~2.3.2" + +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +vite@5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.6.tgz#f9e13503a4c5ccd67312c67803dec921f3bdea7c" + integrity sha512-MD3joyAEBtV7QZPl2JVVUai6zHms3YOmLR+BpMzLlX2Yzjfcc4gTgNi09d/Rua3F4EtC8zdwPU8eQYyib4vVMQ== + dependencies: + esbuild "^0.19.3" + postcss "^8.4.32" + rollup "^4.2.0" + optionalDependencies: + fsevents "~2.3.3" diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 4a3d42847..90127d987 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -1,6 +1,6 @@ import polyfill from "../packages/excalidraw/polyfill"; import LanguageDetector from "i18next-browser-languagedetector"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { trackEvent } from "../packages/excalidraw/analytics"; import { getDefaultAppState } from "../packages/excalidraw/appState"; import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog"; @@ -14,10 +14,9 @@ import { } from "../packages/excalidraw/constants"; import { loadFromBlob } from "../packages/excalidraw/data/blob"; import { - ExcalidrawElement, FileId, NonDeletedExcalidrawElement, - Theme, + OrderedExcalidrawElement, } from "../packages/excalidraw/element/types"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { t } from "../packages/excalidraw/i18n"; @@ -30,7 +29,6 @@ import { } from "../packages/excalidraw/index"; import { AppState, - LibraryItems, ExcalidrawImperativeAPI, BinaryFiles, ExcalidrawInitialDataState, @@ -48,13 +46,13 @@ import { } from "../packages/excalidraw/utils"; import { FIREBASE_STORAGE_PREFIXES, + isExcalidrawPlusSignedUser, STORAGE_KEYS, SYNC_BROWSER_TABS_TIMEOUT, } from "./app_constants"; import Collab, { CollabAPI, collabAPIAtom, - collabDialogShownAtom, isCollaboratingAtom, isOfflineAtom, } from "./collab/Collab"; @@ -65,7 +63,6 @@ import { loadScene, } from "./data"; import { - getLibraryItemsFromStorage, importFromLocalStorage, importUsernameFromLocalStorage, } from "./data/localStorage"; @@ -83,10 +80,13 @@ import { updateStaleImageStatuses } from "./data/FileManager"; import { newElementWith } from "../packages/excalidraw/element/mutateElement"; import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks"; import { loadFilesFromFirebase } from "./data/firebase"; -import { LocalData } from "./data/LocalData"; +import { + LibraryIndexedDBAdapter, + LibraryLocalStorageMigrationAdapter, + LocalData, +} from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import clsx from "clsx"; -import { reconcileElements } from "./collab/reconciliation"; import { parseLibraryTokensFromUrl, useHandleLibrary, @@ -104,6 +104,27 @@ import { ShareableLinkDialog } from "../packages/excalidraw/components/Shareable import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../packages/excalidraw/components/Trans"; +import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; +import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; +import { + RemoteExcalidrawElement, + reconcileElements, +} from "../packages/excalidraw/data/reconcile"; +import { + CommandPalette, + DEFAULT_CATEGORIES, +} from "../packages/excalidraw/components/CommandPalette/CommandPalette"; +import { + GithubIcon, + XBrandIcon, + DiscordIcon, + ExcalLogo, + usersIcon, + exportToPlus, + share, + youtubeIcon, +} from "../packages/excalidraw/components/icons"; +import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; polyfill(); @@ -252,7 +273,7 @@ const initializeScene = async (opts: { }, elements: reconcileElements( scene?.elements || [], - excalidrawAPI.getSceneElementsIncludingDeleted(), + excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[], excalidrawAPI.getAppState(), ), }, @@ -283,6 +304,9 @@ const ExcalidrawWrapper = () => { const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const { editorTheme } = useHandleAppTheme(); + // initial state // --------------------------------------------------------------------------- @@ -305,15 +329,18 @@ const ExcalidrawWrapper = () => { const [excalidrawAPI, excalidrawRefCallback] = useCallbackRefState(); + const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [collabAPI] = useAtom(collabAPIAtom); - const [, setCollabDialogShown] = useAtom(collabDialogShownAtom); const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); + const collabError = useAtomValue(collabErrorIndicatorAtom); useHandleLibrary({ excalidrawAPI, - getInitialLibraryItems: getLibraryItemsFromStorage, + adapter: LibraryIndexedDBAdapter, + // TODO maybe remove this in several months (shipped: 24-03-11) + migrationAdapter: LibraryLocalStorageMigrationAdapter, }); useEffect(() => { @@ -443,8 +470,12 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...localDataState, }); - excalidrawAPI.updateLibrary({ - libraryItems: getLibraryItemsFromStorage(), + LibraryIndexedDBAdapter.load().then((data) => { + if (data) { + excalidrawAPI.updateLibrary({ + libraryItems: data.libraryItems, + }); + } }); collabAPI?.setUsername(username || ""); } @@ -539,25 +570,8 @@ const ExcalidrawWrapper = () => { languageDetector.cacheUserLanguage(langCode); }, [langCode]); - const [theme, setTheme] = useState( - () => - (localStorage.getItem( - STORAGE_KEYS.LOCAL_STORAGE_THEME, - ) as Theme | null) || - // FIXME migration from old LS scheme. Can be removed later. #5660 - importFromLocalStorage().appState?.theme || - THEME.LIGHT, - ); - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme); - // currently only used for body styling during init (see public/index.html), - // but may change in the future - document.documentElement.classList.toggle("dark", theme === THEME.DARK); - }, [theme]); - const onChange = ( - elements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles, ) => { @@ -565,8 +579,6 @@ const ExcalidrawWrapper = () => { collabAPI.syncElements(elements); } - setTheme(appState.theme); - // this check is redundant, but since this is a hot path, it's best // not to evaludate the nested expression every time if (!LocalData.isSavePaused()) { @@ -607,37 +619,38 @@ const ExcalidrawWrapper = () => { exportedElements: readonly NonDeletedExcalidrawElement[], appState: Partial, files: BinaryFiles, - canvas: HTMLCanvasElement, ) => { if (exportedElements.length === 0) { throw new Error(t("alerts.cannotExportEmptyCanvas")); } - if (canvas) { - try { - const { url, errorMessage } = await exportToBackend( - exportedElements, - { - ...appState, - viewBackgroundColor: appState.exportBackground - ? appState.viewBackgroundColor - : getDefaultAppState().viewBackgroundColor, - }, - files, - ); + try { + const { url, errorMessage } = await exportToBackend( + exportedElements, + { + ...appState, + viewBackgroundColor: appState.exportBackground + ? appState.viewBackgroundColor + : getDefaultAppState().viewBackgroundColor, + }, + files, + ); - if (errorMessage) { - throw new Error(errorMessage); - } + if (errorMessage) { + throw new Error(errorMessage); + } - if (url) { - setLatestShareableLink(url); - } - } catch (error: any) { - if (error.name !== "AbortError") { - const { width, height } = canvas; - console.error(error, { width, height }); - throw new Error(error.message); - } + if (url) { + setLatestShareableLink(url); + } + } catch (error: any) { + if (error.name !== "AbortError") { + const { width, height } = appState; + console.error(error, { + width, + height, + devicePixelRatio: window.devicePixelRatio, + }); + throw new Error(error.message); } } }; @@ -655,17 +668,13 @@ const ExcalidrawWrapper = () => { ); }; - const onLibraryChange = async (items: LibraryItems) => { - if (!items.length) { - localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); - return; - } - const serializedItems = JSON.stringify(items); - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); - }; - const isOffline = useAtomValue(isOfflineAtom); + const onCollabDialogOpen = useCallback( + () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), + [setShareDialogState], + ); + // browsers generally prevent infinite self-embedding, there are // cases where it still happens, and while we disallow self-embedding // by not whitelisting our own origin, this serves as an additional guard @@ -685,6 +694,45 @@ const ExcalidrawWrapper = () => { ); } + const ExcalidrawPlusCommand = { + label: "Excalidraw+", + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon:
{ExcalLogo}
, + keywords: ["plus", "cloud", "server"], + perform: () => { + window.open( + `${ + import.meta.env.VITE_APP_PLUS_LP + }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`, + "_blank", + ); + }, + }; + const ExcalidrawPlusAppCommand = { + label: "Sign up", + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon:
{ExcalLogo}
, + keywords: [ + "excalidraw", + "plus", + "cloud", + "server", + "signin", + "login", + "signup", + ], + perform: () => { + window.open( + `${ + import.meta.env.VITE_APP_PLUS_APP + }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`, + "_blank", + ); + }, + }; + return (
{ toggleTheme: true, export: { onExportToBackend, - renderCustomUI: (elements, appState, files) => { - return ( - { - excalidrawAPI?.updateScene({ - appState: { - errorMessage: error.message, - }, - }); - }} - onSuccess={() => { - excalidrawAPI?.updateScene({ - appState: { openDialog: null }, - }); - }} - /> - ); - }, + renderCustomUI: excalidrawAPI + ? (elements, appState, files) => { + return ( + { + excalidrawAPI?.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + }} + onSuccess={() => { + excalidrawAPI.updateScene({ + appState: { openDialog: null }, + }); + }} + /> + ); + } + : undefined, }, }, }} @@ -731,28 +782,34 @@ const ExcalidrawWrapper = () => { renderCustomStats={renderCustomStats} detectScroll={false} handleKeyboardGlobally={true} - onLibraryChange={onLibraryChange} autoFocus={true} - theme={theme} + theme={editorTheme} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; } return ( - setCollabDialogShown(true)} - /> +
+ {collabError.message && } + + setShareDialogState({ isOpen: true, type: "share" }) + } + /> +
); }} > setAppTheme(theme)} /> @@ -767,6 +824,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.getSceneElements(), excalidrawAPI.getAppState(), excalidrawAPI.getFiles(), + excalidrawAPI.getName(), ); }} > @@ -848,11 +906,204 @@ const ExcalidrawWrapper = () => { {excalidrawAPI && !isCollabDisabled && ( )} + + { + if (excalidrawAPI) { + try { + await onExportToBackend( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + ); + } catch (error: any) { + setErrorMessage(error.message); + } + } + }} + /> + {errorMessage && ( setErrorMessage("")}> {errorMessage} )} + + { + setShareDialogState({ + isOpen: true, + type: "collaborationOnly", + }); + }, + }, + { + label: t("roomDialog.button_stopSession"), + category: DEFAULT_CATEGORIES.app, + predicate: () => !!collabAPI?.isCollaborating(), + keywords: [ + "stop", + "session", + "end", + "leave", + "close", + "exit", + "collaboration", + ], + perform: () => { + if (collabAPI) { + collabAPI.stopCollaboration(); + if (!collabAPI.isCollaborating()) { + setShareDialogState({ isOpen: false }); + } + } + }, + }, + { + label: t("labels.share"), + category: DEFAULT_CATEGORIES.app, + predicate: true, + icon: share, + keywords: [ + "link", + "shareable", + "readonly", + "export", + "publish", + "snapshot", + "url", + "collaborate", + "invite", + ], + perform: async () => { + setShareDialogState({ isOpen: true, type: "share" }); + }, + }, + { + label: "GitHub", + icon: GithubIcon, + category: DEFAULT_CATEGORIES.links, + predicate: true, + keywords: [ + "issues", + "bugs", + "requests", + "report", + "features", + "social", + "community", + ], + perform: () => { + window.open( + "https://github.com/excalidraw/excalidraw", + "_blank", + "noopener noreferrer", + ); + }, + }, + { + label: t("labels.followUs"), + icon: XBrandIcon, + category: DEFAULT_CATEGORIES.links, + predicate: true, + keywords: ["twitter", "contact", "social", "community"], + perform: () => { + window.open( + "https://x.com/excalidraw", + "_blank", + "noopener noreferrer", + ); + }, + }, + { + label: t("labels.discordChat"), + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon: DiscordIcon, + keywords: [ + "chat", + "talk", + "contact", + "bugs", + "requests", + "report", + "feedback", + "suggestions", + "social", + "community", + ], + perform: () => { + window.open( + "https://discord.gg/UexuTaE", + "_blank", + "noopener noreferrer", + ); + }, + }, + { + label: "YouTube", + icon: youtubeIcon, + category: DEFAULT_CATEGORIES.links, + predicate: true, + keywords: ["features", "tutorials", "howto", "help", "community"], + perform: () => { + window.open( + "https://youtube.com/@excalidraw", + "_blank", + "noopener noreferrer", + ); + }, + }, + ...(isExcalidrawPlusSignedUser + ? [ + { + ...ExcalidrawPlusAppCommand, + label: "Sign in / Go to Excalidraw+", + }, + ] + : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]), + + { + label: t("overwriteConfirm.action.excalidrawPlus.button"), + category: DEFAULT_CATEGORIES.export, + icon: exportToPlus, + predicate: true, + keywords: ["plus", "export", "save", "backup"], + perform: () => { + if (excalidrawAPI) { + exportToExcalidrawPlus( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + excalidrawAPI.getName(), + ); + } + }, + }, + { + ...CommandPalette.defaultItems.toggleTheme, + perform: () => { + setAppTheme( + editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK, + ); + }, + }, + ]} + />
); diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 179fe52e7..f4b56496d 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -15,11 +15,17 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const WS_EVENTS = { SERVER_VOLATILE: "server-volatile-broadcast", SERVER: "server-broadcast", -}; + USER_FOLLOW_CHANGE: "user-follow", + USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change", +} as const; -export enum WS_SCENE_EVENT_TYPES { +export enum WS_SUBTYPES { + INVALID_RESPONSE = "INVALID_RESPONSE", INIT = "SCENE_INIT", UPDATE = "SCENE_UPDATE", + MOUSE_LOCATION = "MOUSE_LOCATION", + IDLE_STATUS = "IDLE_STATUS", + USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS", } export const FIREBASE_STORAGE_PREFIXES = { @@ -33,10 +39,14 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_COLLAB: "excalidraw-collab", - LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_THEME: "excalidraw-theme", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", + + IDB_LIBRARY: "excalidraw-library", + + // do not use apart from migrations + __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", } as const; export const COOKIES = { diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 6ecdd1575..def3810f9 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -1,22 +1,28 @@ import throttle from "lodash.throttle"; import { PureComponent } from "react"; -import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types"; +import { + ExcalidrawImperativeAPI, + SocketId, +} from "../../packages/excalidraw/types"; import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog"; import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; import { ImportedDataState } from "../../packages/excalidraw/data/types"; import { ExcalidrawElement, InitializedExcalidrawImageElement, + OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { getSceneVersion, restoreElements, + zoomToFitBounds, } from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../packages/excalidraw/types"; import { + assertNever, preventUnload, resolvablePromise, - withBatchedUpdates, + throttleRAF, } from "../../packages/excalidraw/utils"; import { CURSOR_SYNC_TIMEOUT, @@ -24,13 +30,13 @@ import { FIREBASE_STORAGE_PREFIXES, INITIAL_SCENE_UPDATE_TIMEOUT, LOAD_IMAGES_TIMEOUT, - WS_SCENE_EVENT_TYPES, + WS_SUBTYPES, SYNC_FULL_SCENE_INTERVAL_MS, + WS_EVENTS, } from "../app_constants"; import { generateCollaborationLinkData, getCollaborationLink, - getCollabServer, getSyncableElements, SocketUpdateDataSource, SyncableExcalidrawElement, @@ -47,7 +53,6 @@ import { saveUsernameToLocalStorage, } from "../data/localStorage"; import Portal from "./Portal"; -import RoomDialog from "./RoomDialog"; import { t } from "../../packages/excalidraw/i18n"; import { UserIdleState } from "../../packages/excalidraw/types"; import { @@ -65,27 +70,35 @@ import { isInitializedImageElement, } from "../../packages/excalidraw/element/typeChecks"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; -import { - ReconciledElements, - reconcileElements as _reconcileElements, -} from "./reconciliation"; import { decryptData } from "../../packages/excalidraw/data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; -import { atom, useAtom } from "jotai"; +import { atom } from "jotai"; import { appJotaiStore } from "../app-jotai"; +import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; +import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; +import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; +import { collabErrorIndicatorAtom } from "./CollabError"; +import { + ReconciledExcalidrawElement, + RemoteExcalidrawElement, + reconcileElements, +} from "../../packages/excalidraw/data/reconcile"; export const collabAPIAtom = atom(null); -export const collabDialogShownAtom = atom(false); export const isCollaboratingAtom = atom(false); export const isOfflineAtom = atom(false); interface CollabState { - errorMessage: string; + errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; username: string; - activeRoomLink: string; + activeRoomLink: string | null; } +export const activeRoomLinkAtom = atom(null); + type CollabInstance = InstanceType; export interface CollabAPI { @@ -96,32 +109,34 @@ export interface CollabAPI { stopCollaboration: CollabInstance["stopCollaboration"]; syncElements: CollabInstance["syncElements"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; - setUsername: (username: string) => void; + setUsername: CollabInstance["setUsername"]; + getUsername: CollabInstance["getUsername"]; + getActiveRoomLink: CollabInstance["getActiveRoomLink"]; + setCollabError: CollabInstance["setErrorDialog"]; } -interface PublicProps { +interface CollabProps { excalidrawAPI: ExcalidrawImperativeAPI; } -type Props = PublicProps & { modalIsShown: boolean }; - -class Collab extends PureComponent { +class Collab extends PureComponent { portal: Portal; fileManager: FileManager; - excalidrawAPI: Props["excalidrawAPI"]; + excalidrawAPI: CollabProps["excalidrawAPI"]; activeIntervalId: number | null; idleTimeoutId: number | null; private socketInitializationTimer?: number; private lastBroadcastedOrReceivedSceneVersion: number = -1; - private collaborators = new Map(); + private collaborators = new Map(); - constructor(props: Props) { + constructor(props: CollabProps) { super(props); this.state = { - errorMessage: "", + errorMessage: null, + dialogNotifiedErrors: {}, username: importUsernameFromLocalStorage() || "", - activeRoomLink: "", + activeRoomLink: null, }; this.portal = new Portal(this); this.fileManager = new FileManager({ @@ -154,12 +169,28 @@ class Collab extends PureComponent { this.idleTimeoutId = null; } + private onUmmount: (() => void) | null = null; + componentDidMount() { window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener("online", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener(EVENT.UNLOAD, this.onUnload); + const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => { + this.portal.socket && this.portal.broadcastUserFollowed(payload); + }); + const throttledRelayUserViewportBounds = throttleRAF( + this.relayVisibleSceneBounds, + ); + const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() => + throttledRelayUserViewportBounds(), + ); + this.onUmmount = () => { + unsubOnUserFollow(); + unsubOnScrollChange(); + }; + this.onOfflineStatusToggle(); const collabAPI: CollabAPI = { @@ -170,6 +201,9 @@ class Collab extends PureComponent { fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, stopCollaboration: this.stopCollaboration, setUsername: this.setUsername, + getUsername: this.getUsername, + getActiveRoomLink: this.getActiveRoomLink, + setCollabError: this.setErrorDialog, }; appJotaiStore.set(collabAPIAtom, collabAPI); @@ -207,6 +241,7 @@ class Collab extends PureComponent { window.clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } + this.onUmmount?.(); } isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; @@ -241,24 +276,39 @@ class Collab extends PureComponent { syncableElements: readonly SyncableExcalidrawElement[], ) => { try { - const savedData = await saveToFirebase( + const storedElements = await saveToFirebase( this.portal, syncableElements, this.excalidrawAPI.getAppState(), ); - if (this.isCollaborating() && savedData && savedData.reconciledElements) { - this.handleRemoteSceneUpdate( - this.reconcileElements(savedData.reconciledElements), - ); + this.resetErrorIndicator(); + + if (this.isCollaborating() && storedElements) { + this.handleRemoteSceneUpdate(this._reconcileElements(storedElements)); } } catch (error: any) { - this.setState({ - // firestore doesn't return a specific error code when size exceeded - errorMessage: /is longer than.*?bytes/.test(error.message) - ? t("errors.collabSaveFailed_sizeExceeded") - : t("errors.collabSaveFailed"), - }); + const errorMessage = /is longer than.*?bytes/.test(error.message) + ? t("errors.collabSaveFailed_sizeExceeded") + : t("errors.collabSaveFailed"); + + if ( + !this.state.dialogNotifiedErrors[errorMessage] || + !this.isCollaborating() + ) { + this.setErrorDialog(errorMessage); + this.setState({ + dialogNotifiedErrors: { + ...this.state.dialogNotifiedErrors, + [errorMessage]: true, + }, + }); + } + + if (this.isCollaborating()) { + this.setErrorIndicator(errorMessage); + } + console.error(error); } }; @@ -267,6 +317,7 @@ class Collab extends PureComponent { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); + this.resetErrorIndicator(true); this.saveCollabRoomToFirebase( getSyncableElements( @@ -316,9 +367,7 @@ class Collab extends PureComponent { this.fileManager.reset(); if (!opts?.isUnload) { this.setIsCollaborating(false); - this.setState({ - activeRoomLink: "", - }); + this.setActiveRoomLink(null); this.collaborators = new Map(); this.excalidrawAPI.updateScene({ collaborators: this.collaborators, @@ -359,7 +408,7 @@ class Collab extends PureComponent { iv: Uint8Array, encryptedData: ArrayBuffer, decryptionKey: string, - ) => { + ): Promise> => { try { const decrypted = await decryptData(iv, encryptedData, decryptionKey); @@ -371,7 +420,7 @@ class Collab extends PureComponent { window.alert(t("alerts.decryptFailed")); console.error(error); return { - type: "INVALID_RESPONSE", + type: WS_SUBTYPES.INVALID_RESPONSE, }; } }; @@ -380,11 +429,11 @@ class Collab extends PureComponent { startCollaboration = async ( existingRoomLinkData: null | { roomId: string; roomKey: string }, - ): Promise => { + ) => { if (!this.state.username) { import("@excalidraw/random-username").then(({ getRandomUsername }) => { const username = getRandomUsername(); - this.onUsernameChange(username); + this.setUsername(username); }); } @@ -406,7 +455,11 @@ class Collab extends PureComponent { ); } - const scenePromise = resolvablePromise(); + // TODO: `ImportedDataState` type here seems abused + const scenePromise = resolvablePromise< + | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] }) + | null + >(); this.setIsCollaborating(true); LocalData.pauseSave("collaboration"); @@ -426,13 +479,9 @@ class Collab extends PureComponent { this.fallbackInitializationHandler = fallbackInitializationHandler; try { - const socketServerData = await getCollabServer(); - this.portal.socket = this.portal.open( - socketIOClient(socketServerData.url, { - transports: socketServerData.polling - ? ["websocket", "polling"] - : ["websocket"], + socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, { + transports: ["websocket", "polling"], }), roomId, roomKey, @@ -441,7 +490,7 @@ class Collab extends PureComponent { this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); - this.setState({ errorMessage: error.message }); + this.setErrorDialog(error.message); return null; } @@ -487,13 +536,14 @@ class Collab extends PureComponent { ); switch (decryptedData.type) { - case "INVALID_RESPONSE": + case WS_SUBTYPES.INVALID_RESPONSE: return; - case WS_SCENE_EVENT_TYPES.INIT: { + case WS_SUBTYPES.INIT: { if (!this.portal.socketInitialized) { this.initializeRoom({ fetchScene: false }); const remoteElements = decryptedData.payload.elements; - const reconciledElements = this.reconcileElements(remoteElements); + const reconciledElements = + this._reconcileElements(remoteElements); this.handleRemoteSceneUpdate(reconciledElements, { init: true, }); @@ -505,41 +555,75 @@ class Collab extends PureComponent { } break; } - case WS_SCENE_EVENT_TYPES.UPDATE: + case WS_SUBTYPES.UPDATE: this.handleRemoteSceneUpdate( - this.reconcileElements(decryptedData.payload.elements), + this._reconcileElements(decryptedData.payload.elements), ); break; - case "MOUSE_LOCATION": { + case WS_SUBTYPES.MOUSE_LOCATION: { const { pointer, button, username, selectedElementIds } = decryptedData.payload; + const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = decryptedData.payload.socketId || // @ts-ignore legacy, see #2094 (#2097) decryptedData.payload.socketID; - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.pointer = pointer; - user.button = button; - user.selectedElementIds = selectedElementIds; - user.username = username; - collaborators.set(socketId, user); + this.updateCollaborator(socketId, { + pointer, + button, + selectedElementIds, + username, + }); + + break; + } + + case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: { + const { sceneBounds, socketId } = decryptedData.payload; + + const appState = this.excalidrawAPI.getAppState(); + + // we're not following the user + // (shouldn't happen, but could be late message or bug upstream) + if (appState.userToFollow?.socketId !== socketId) { + console.warn( + `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`, + ); + return; + } + + // cross-follow case, ignore updates in this case + if ( + appState.userToFollow && + appState.followedBy.has(appState.userToFollow.socketId) + ) { + return; + } + this.excalidrawAPI.updateScene({ - collaborators, + appState: zoomToFitBounds({ + appState, + bounds: sceneBounds, + fitToViewport: true, + viewportZoomFactor: 1, + }).appState, + }); + + break; + } + + case WS_SUBTYPES.IDLE_STATUS: { + const { userState, socketId, username } = decryptedData.payload; + this.updateCollaborator(socketId, { + userState, + username, }); break; } - case "IDLE_STATUS": { - const { userState, socketId, username } = decryptedData.payload; - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.userState = userState; - user.username = username; - this.excalidrawAPI.updateScene({ - collaborators, - }); - break; + + default: { + assertNever(decryptedData, null); } } }, @@ -556,11 +640,20 @@ class Collab extends PureComponent { scenePromise.resolve(sceneData); }); + this.portal.socket.on( + WS_EVENTS.USER_FOLLOW_ROOM_CHANGE, + (followedBy: SocketId[]) => { + this.excalidrawAPI.updateScene({ + appState: { followedBy: new Set(followedBy) }, + }); + + this.relayVisibleSceneBounds({ force: true }); + }, + ); + this.initializeIdleDetector(); - this.setState({ - activeRoomLink: window.location.href, - }); + this.setActiveRoomLink(window.location.href); return scenePromise; }; @@ -612,17 +705,15 @@ class Collab extends PureComponent { return null; }; - private reconcileElements = ( + private _reconcileElements = ( remoteElements: readonly ExcalidrawElement[], - ): ReconciledElements => { + ): ReconciledExcalidrawElement[] => { const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); - - remoteElements = restoreElements(remoteElements, null); - - const reconciledElements = _reconcileElements( + const restoredRemoteElements = restoreElements(remoteElements, null); + const reconciledElements = reconcileElements( localElements, - remoteElements, + restoredRemoteElements as RemoteExcalidrawElement[], appState, ); @@ -653,7 +744,7 @@ class Collab extends PureComponent { }, LOAD_IMAGES_TIMEOUT); private handleRemoteSceneUpdate = ( - elements: ReconciledElements, + elements: ReconciledExcalidrawElement[], { init = false }: { init?: boolean } = {}, ) => { this.excalidrawAPI.updateScene({ @@ -724,20 +815,39 @@ class Collab extends PureComponent { document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange); }; - setCollaborators(sockets: string[]) { + setCollaborators(sockets: SocketId[]) { const collaborators: InstanceType["collaborators"] = new Map(); for (const socketId of sockets) { - if (this.collaborators.has(socketId)) { - collaborators.set(socketId, this.collaborators.get(socketId)!); - } else { - collaborators.set(socketId, {}); - } + collaborators.set( + socketId, + Object.assign({}, this.collaborators.get(socketId), { + isCurrentUser: socketId === this.portal.socket?.id, + }), + ); } this.collaborators = collaborators; this.excalidrawAPI.updateScene({ collaborators }); } + updateCollaborator = (socketId: SocketId, updates: Partial) => { + const collaborators = new Map(this.collaborators); + const user: Mutable = Object.assign( + {}, + collaborators.get(socketId), + updates, + { + isCurrentUser: socketId === this.portal.socket?.id, + }, + ); + collaborators.set(socketId, user); + this.collaborators = collaborators; + + this.excalidrawAPI.updateScene({ + collaborators, + }); + }; + public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { this.lastBroadcastedOrReceivedSceneVersion = version; }; @@ -763,29 +873,42 @@ class Collab extends PureComponent { CURSOR_SYNC_TIMEOUT, ); + relayVisibleSceneBounds = (props?: { force: boolean }) => { + const appState = this.excalidrawAPI.getAppState(); + + if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) { + this.portal.broadcastVisibleSceneBounds( + { + sceneBounds: getVisibleSceneBounds(appState), + }, + `follow@${this.portal.socket.id}`, + ); + } + }; + onIdleStateChange = (userState: UserIdleState) => { this.portal.broadcastIdleChange(userState); }; - broadcastElements = (elements: readonly ExcalidrawElement[]) => { + broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => { if ( getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { - this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false); + this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.queueBroadcastAllElements(); } }; - syncElements = (elements: readonly ExcalidrawElement[]) => { + syncElements = (elements: readonly OrderedExcalidrawElement[]) => { this.broadcastElements(elements); this.queueSaveToFirebase(); }; queueBroadcastAllElements = throttle(() => { this.portal.broadcastScene( - WS_SCENE_EVENT_TYPES.UPDATE, + WS_SUBTYPES.UPDATE, this.excalidrawAPI.getSceneElementsIncludingDeleted(), true, ); @@ -811,41 +934,49 @@ class Collab extends PureComponent { { leading: false }, ); - handleClose = () => { - appJotaiStore.set(collabDialogShownAtom, false); - }; - setUsername = (username: string) => { this.setState({ username }); - }; - - onUsernameChange = (username: string) => { - this.setUsername(username); saveUsernameToLocalStorage(username); }; - render() { - const { username, errorMessage, activeRoomLink } = this.state; + getUsername = () => this.state.username; - const { modalIsShown } = this.props; + setActiveRoomLink = (activeRoomLink: string | null) => { + this.setState({ activeRoomLink }); + appJotaiStore.set(activeRoomLinkAtom, activeRoomLink); + }; + + getActiveRoomLink = () => this.state.activeRoomLink; + + setErrorIndicator = (errorMessage: string | null) => { + appJotaiStore.set(collabErrorIndicatorAtom, { + message: errorMessage, + nonce: Date.now(), + }); + }; + + resetErrorIndicator = (resetDialogNotifiedErrors = false) => { + appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 }); + if (resetDialogNotifiedErrors) { + this.setState({ + dialogNotifiedErrors: {}, + }); + } + }; + + setErrorDialog = (errorMessage: string | null) => { + this.setState({ + errorMessage, + }); + }; + + render() { + const { errorMessage } = this.state; return ( <> - {modalIsShown && ( - this.startCollaboration(null)} - onRoomDestroy={this.stopCollaboration} - setErrorMessage={(errorMessage) => { - this.setState({ errorMessage }); - }} - /> - )} - {errorMessage && ( - this.setState({ errorMessage: "" })}> + {errorMessage != null && ( + this.setErrorDialog(null)}> {errorMessage} )} @@ -864,11 +995,6 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { window.collab = window.collab || ({} as Window["collab"]); } -const _Collab: React.FC = (props) => { - const [collabDialogShown] = useAtom(collabDialogShownAtom); - return ; -}; - -export default _Collab; +export default Collab; export type TCollabClass = Collab; diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss new file mode 100644 index 000000000..085dc5609 --- /dev/null +++ b/excalidraw-app/collab/CollabError.scss @@ -0,0 +1,35 @@ +@import "../../packages/excalidraw/css/variables.module.scss"; + +.excalidraw { + .collab-errors-button { + width: 26px; + height: 26px; + margin-inline-end: 1rem; + + color: var(--color-danger); + + flex-shrink: 0; + } + + .collab-errors-button-shake { + animation: strong-shake 0.15s 6; + } + + @keyframes strong-shake { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(10deg); + } + 50% { + transform: rotate(0eg); + } + 75% { + transform: rotate(-10deg); + } + 100% { + transform: rotate(0deg); + } + } +} diff --git a/excalidraw-app/collab/CollabError.tsx b/excalidraw-app/collab/CollabError.tsx new file mode 100644 index 000000000..45a98ac8d --- /dev/null +++ b/excalidraw-app/collab/CollabError.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from "../../packages/excalidraw/components/Tooltip"; +import { warning } from "../../packages/excalidraw/components/icons"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; + +import "./CollabError.scss"; +import { atom } from "jotai"; + +type ErrorIndicator = { + message: string | null; + /** used to rerun the useEffect responsible for animation */ + nonce: number; +}; + +export const collabErrorIndicatorAtom = atom({ + message: null, + nonce: 0, +}); + +const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => { + const [isAnimating, setIsAnimating] = useState(false); + const clearAnimationRef = useRef(); + + useEffect(() => { + setIsAnimating(true); + clearAnimationRef.current = setTimeout(() => { + setIsAnimating(false); + }, 1000); + + return () => { + clearTimeout(clearAnimationRef.current); + }; + }, [collabError.message, collabError.nonce]); + + if (!collabError.message) { + return null; + } + + return ( + +
+ {warning} +
+
+ ); +}; + +CollabError.displayName = "CollabError"; + +export default CollabError; diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 4e5054329..8b5e5680e 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -2,27 +2,27 @@ import { isSyncableElement, SocketUpdateData, SocketUpdateDataSource, + SyncableExcalidrawElement, } from "../data"; import { TCollabClass } from "./Collab"; -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; +import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types"; +import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; import { - WS_EVENTS, - FILE_UPLOAD_TIMEOUT, - WS_SCENE_EVENT_TYPES, -} from "../app_constants"; -import { UserIdleState } from "../../packages/excalidraw/types"; + OnUserFollowedPayload, + SocketId, + UserIdleState, +} from "../../packages/excalidraw/types"; import { trackEvent } from "../../packages/excalidraw/analytics"; import throttle from "lodash.throttle"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; -import { BroadcastedExcalidrawElement } from "./reconciliation"; import { encryptData } from "../../packages/excalidraw/data/encryption"; -import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; +import type { Socket } from "socket.io-client"; class Portal { collab: TCollabClass; - socket: SocketIOClient.Socket | null = null; + socket: Socket | null = null; socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized roomId: string | null = null; roomKey: string | null = null; @@ -32,7 +32,7 @@ class Portal { this.collab = collab; } - open(socket: SocketIOClient.Socket, id: string, key: string) { + open(socket: Socket, id: string, key: string) { this.socket = socket; this.roomId = id; this.roomKey = key; @@ -46,12 +46,12 @@ class Portal { }); this.socket.on("new-user", async (_socketId: string) => { this.broadcastScene( - WS_SCENE_EVENT_TYPES.INIT, + WS_SUBTYPES.INIT, this.collab.getSceneElementsIncludingDeleted(), /* syncAll */ true, ); }); - this.socket.on("room-user-change", (clients: string[]) => { + this.socket.on("room-user-change", (clients: SocketId[]) => { this.collab.setCollaborators(clients); }); @@ -83,6 +83,7 @@ class Portal { async _broadcastSocketData( data: SocketUpdateData, volatile: boolean = false, + roomId?: string, ) { if (this.isOpen()) { const json = JSON.stringify(data); @@ -91,7 +92,7 @@ class Portal { this.socket?.emit( volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, - this.roomId, + roomId ?? this.roomId, encryptedBuffer, iv, ); @@ -130,36 +131,28 @@ class Portal { }, FILE_UPLOAD_TIMEOUT); broadcastScene = async ( - updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE, - allElements: readonly ExcalidrawElement[], + updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, + elements: readonly OrderedExcalidrawElement[], syncAll: boolean, ) => { - if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) { + if (updateType === WS_SUBTYPES.INIT && !syncAll) { throw new Error("syncAll must be true when sending SCENE.INIT"); } // sync out only the elements we think we need to to save bandwidth. // periodically we'll resync the whole thing to make sure no one diverges // due to a dropped message (server goes down etc). - const syncableElements = allElements.reduce( - (acc, element: BroadcastedExcalidrawElement, idx, elements) => { - if ( - (syncAll || - !this.broadcastedElementVersions.has(element.id) || - element.version > - this.broadcastedElementVersions.get(element.id)!) && - isSyncableElement(element) - ) { - acc.push({ - ...element, - // z-index info for the reconciler - [PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id, - }); - } - return acc; - }, - [] as BroadcastedExcalidrawElement[], - ); + const syncableElements = elements.reduce((acc, element) => { + if ( + (syncAll || + !this.broadcastedElementVersions.has(element.id) || + element.version > this.broadcastedElementVersions.get(element.id)!) && + isSyncableElement(element) + ) { + acc.push(element); + } + return acc; + }, [] as SyncableExcalidrawElement[]); const data: SocketUpdateDataSource[typeof updateType] = { type: updateType, @@ -183,9 +176,9 @@ class Portal { broadcastIdleChange = (userState: UserIdleState) => { if (this.socket?.id) { const data: SocketUpdateDataSource["IDLE_STATUS"] = { - type: "IDLE_STATUS", + type: WS_SUBTYPES.IDLE_STATUS, payload: { - socketId: this.socket.id, + socketId: this.socket.id as SocketId, userState, username: this.collab.state.username, }, @@ -203,9 +196,9 @@ class Portal { }) => { if (this.socket?.id) { const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { - type: "MOUSE_LOCATION", + type: WS_SUBTYPES.MOUSE_LOCATION, payload: { - socketId: this.socket.id, + socketId: this.socket.id as SocketId, pointer: payload.pointer, button: payload.button || "up", selectedElementIds: @@ -213,12 +206,43 @@ class Portal { username: this.collab.state.username, }, }; + return this._broadcastSocketData( data as SocketUpdateData, true, // volatile ); } }; + + broadcastVisibleSceneBounds = ( + payload: { + sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"]; + }, + roomId: string, + ) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = { + type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS, + payload: { + socketId: this.socket.id as SocketId, + username: this.collab.state.username, + sceneBounds: payload.sceneBounds, + }, + }; + + return this._broadcastSocketData( + data as SocketUpdateData, + true, // volatile + roomId, + ); + } + }; + + broadcastUserFollowed = (payload: OnUserFollowedPayload) => { + if (this.socket?.id) { + this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload); + } + }; } export default Portal; diff --git a/excalidraw-app/collab/RoomDialog.tsx b/excalidraw-app/collab/RoomDialog.tsx index 48bc12446..74266d3d9 100644 --- a/excalidraw-app/collab/RoomDialog.tsx +++ b/excalidraw-app/collab/RoomDialog.tsx @@ -65,19 +65,18 @@ export const RoomModal = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; @@ -120,7 +119,7 @@ export const RoomModal = ({ size="large" variant="icon" label="Share" - startIcon={getShareIcon()} + icon={getShareIcon()} className="RoomDialog__active__share" onClick={shareRoomLink} /> @@ -130,7 +129,7 @@ export const RoomModal = ({ @@ -166,7 +165,7 @@ export const RoomModal = ({ variant="outlined" color="danger" label={t("roomDialog.button_stopSession")} - startIcon={playerStopFilledIcon} + icon={playerStopFilledIcon} onClick={() => { trackEvent("share", "room closed"); onRoomDestroy(); @@ -195,7 +194,7 @@ export const RoomModal = ({ { trackEvent("share", "room creation", `ui (${getFrame()})`); onRoomCreate(); diff --git a/excalidraw-app/collab/reconciliation.ts b/excalidraw-app/collab/reconciliation.ts deleted file mode 100644 index 15e17ed42..000000000 --- a/excalidraw-app/collab/reconciliation.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; -import { AppState } from "../../packages/excalidraw/types"; -import { arrayToMapWithIndex } from "../../packages/excalidraw/utils"; - -export type ReconciledElements = readonly ExcalidrawElement[] & { - _brand: "reconciledElements"; -}; - -export type BroadcastedExcalidrawElement = ExcalidrawElement & { - [PRECEDING_ELEMENT_KEY]?: string; -}; - -const shouldDiscardRemoteElement = ( - localAppState: AppState, - local: ExcalidrawElement | undefined, - remote: BroadcastedExcalidrawElement, -): boolean => { - if ( - local && - // local element is being edited - (local.id === localAppState.editingElement?.id || - local.id === localAppState.resizingElement?.id || - local.id === localAppState.draggingElement?.id || - // local element is newer - local.version > remote.version || - // resolve conflicting edits deterministically by taking the one with - // the lowest versionNonce - (local.version === remote.version && - local.versionNonce < remote.versionNonce)) - ) { - return true; - } - return false; -}; - -export const reconcileElements = ( - localElements: readonly ExcalidrawElement[], - remoteElements: readonly BroadcastedExcalidrawElement[], - localAppState: AppState, -): ReconciledElements => { - const localElementsData = - arrayToMapWithIndex(localElements); - - const reconciledElements: ExcalidrawElement[] = localElements.slice(); - - const duplicates = new WeakMap(); - - let cursor = 0; - let offset = 0; - - let remoteElementIdx = -1; - for (const remoteElement of remoteElements) { - remoteElementIdx++; - - const local = localElementsData.get(remoteElement.id); - - if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { - if (remoteElement[PRECEDING_ELEMENT_KEY]) { - delete remoteElement[PRECEDING_ELEMENT_KEY]; - } - - continue; - } - - // Mark duplicate for removal as it'll be replaced with the remote element - if (local) { - // Unless the remote and local elements are the same element in which case - // we need to keep it as we'd otherwise discard it from the resulting - // array. - if (local[0] === remoteElement) { - continue; - } - duplicates.set(local[0], true); - } - - // parent may not be defined in case the remote client is running an older - // excalidraw version - const parent = - remoteElement[PRECEDING_ELEMENT_KEY] || - remoteElements[remoteElementIdx - 1]?.id || - null; - - if (parent != null) { - delete remoteElement[PRECEDING_ELEMENT_KEY]; - - // ^ indicates the element is the first in elements array - if (parent === "^") { - offset++; - if (cursor === 0) { - reconciledElements.unshift(remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - cursor - offset, - ]); - } else { - reconciledElements.splice(cursor + 1, 0, remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - cursor + 1 - offset, - ]); - cursor++; - } - } else { - let idx = localElementsData.has(parent) - ? localElementsData.get(parent)![1] - : null; - if (idx != null) { - idx += offset; - } - if (idx != null && idx >= cursor) { - reconciledElements.splice(idx + 1, 0, remoteElement); - offset++; - localElementsData.set(remoteElement.id, [ - remoteElement, - idx + 1 - offset, - ]); - cursor = idx + 1; - } else if (idx != null) { - reconciledElements.splice(cursor + 1, 0, remoteElement); - offset++; - localElementsData.set(remoteElement.id, [ - remoteElement, - cursor + 1 - offset, - ]); - cursor++; - } else { - reconciledElements.push(remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - reconciledElements.length - 1 - offset, - ]); - } - } - // no parent z-index information, local element exists → replace in place - } else if (local) { - reconciledElements[local[1]] = remoteElement; - localElementsData.set(remoteElement.id, [remoteElement, local[1]]); - // otherwise push to the end - } else { - reconciledElements.push(remoteElement); - localElementsData.set(remoteElement.id, [ - remoteElement, - reconciledElements.length - 1 - offset, - ]); - } - } - - const ret: readonly ExcalidrawElement[] = reconciledElements.filter( - (element) => !duplicates.has(element), - ); - - return ret as ReconciledElements; -}; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 34a2ee3ae..2cc055b32 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -1,12 +1,19 @@ import React from "react"; -import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { + arrowBarToLeftIcon, + ExcalLogo, +} from "../../packages/excalidraw/components/icons"; +import { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; +import { isExcalidrawPlusSignedUser } from "../app_constants"; import { LanguageList } from "./LanguageList"; export const AppMainMenu: React.FC<{ - setCollabDialogShown: (toggle: boolean) => any; + onCollabDialogOpen: () => any; isCollaborating: boolean; isCollabEnabled: boolean; + theme: Theme | "system"; + setTheme: (theme: Theme | "system") => void; }> = React.memo((props) => { return ( @@ -17,25 +24,38 @@ export const AppMainMenu: React.FC<{ {props.isCollabEnabled && ( props.setCollabDialogShown(true)} + onSelect={() => props.onCollabDialogOpen()} /> )} - + Excalidraw+ + + {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} + - + diff --git a/excalidraw-app/components/AppWelcomeScreen.tsx b/excalidraw-app/components/AppWelcomeScreen.tsx index a5176c2ff..f730684bc 100644 --- a/excalidraw-app/components/AppWelcomeScreen.tsx +++ b/excalidraw-app/components/AppWelcomeScreen.tsx @@ -1,12 +1,12 @@ import React from "react"; -import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { arrowBarToLeftIcon } from "../../packages/excalidraw/components/icons"; import { useI18n } from "../../packages/excalidraw/i18n"; import { WelcomeScreen } from "../../packages/excalidraw/index"; import { isExcalidrawPlusSignedUser } from "../app_constants"; import { POINTER_EVENTS } from "../../packages/excalidraw/constants"; export const AppWelcomeScreen: React.FC<{ - setCollabDialogShown: (toggle: boolean) => any; + onCollabDialogOpen: () => any; isCollabEnabled: boolean; }> = React.memo((props) => { const { t } = useI18n(); @@ -52,7 +52,7 @@ export const AppWelcomeScreen: React.FC<{ {props.isCollabEnabled && ( props.setCollabDialogShown(true)} + onSelect={() => props.onCollabDialogOpen()} /> )} {!isExcalidrawPlusSignedUser && ( @@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{ import.meta.env.VITE_APP_PLUS_LP }/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`} shortcut={null} - icon={PlusPromoIcon} + icon={arrowBarToLeftIcon} > - Try Excalidraw Plus! + Sign up )} diff --git a/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 4c566950b..bfbb4a556 100644 --- a/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/excalidraw-app/components/ExportToExcalidrawPlus.tsx @@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async ( elements: readonly NonDeletedExcalidrawElement[], appState: Partial, files: BinaryFiles, + name: string, ) => { const firebase = await loadFirebaseStorage(); @@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async ( .ref(`/migrations/scenes/${id}`) .put(blob, { customMetadata: { - data: JSON.stringify({ version: 2, name: appState.name }), + data: JSON.stringify({ version: 2, name }), created: Date.now().toString(), }, }); @@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{ elements: readonly NonDeletedExcalidrawElement[]; appState: Partial; files: BinaryFiles; + name: string; onError: (error: Error) => void; onSuccess: () => void; -}> = ({ elements, appState, files, onError, onSuccess }) => { +}> = ({ elements, appState, files, name, onError, onSuccess }) => { const { t } = useI18n(); return ( @@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{ onClick={async () => { try { trackEvent("export", "eplus", `ui (${getFrame()})`); - await exportToExcalidrawPlus(elements, appState, files); + await exportToExcalidrawPlus(elements, appState, files, name); onSuccess(); } catch (error: any) { console.error(error); diff --git a/excalidraw-app/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx index f796906d6..3dbf12ceb 100644 --- a/excalidraw-app/components/TopErrorBoundary.tsx +++ b/excalidraw-app/components/TopErrorBoundary.tsx @@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component< window.open( `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`, + "_blank", + "noopener noreferrer", ); } diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index a8a6c41b2..9d19e073b 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -10,8 +10,18 @@ * (localStorage, indexedDB). */ -import { createStore, entries, del, getMany, set, setMany } from "idb-keyval"; +import { + createStore, + entries, + del, + getMany, + set, + setMany, + get, +} from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; +import { LibraryPersistedData } from "../../packages/excalidraw/data/library"; +import { ImportedDataState } from "../../packages/excalidraw/data/types"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { ExcalidrawElement, @@ -22,6 +32,7 @@ import { BinaryFileData, BinaryFiles, } from "../../packages/excalidraw/types"; +import { MaybePromise } from "../../packages/excalidraw/utility-types"; import { debounce } from "../../packages/excalidraw/utils"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { FileManager } from "./FileManager"; @@ -183,3 +194,52 @@ export class LocalData { }, }); } +export class LibraryIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = STORAGE_KEYS.IDB_LIBRARY; + /** library data store key */ + private static key = "libraryData"; + + private static store = createStore( + `${LibraryIndexedDBAdapter.idb_name}-db`, + `${LibraryIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get( + LibraryIndexedDBAdapter.key, + LibraryIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: LibraryPersistedData): MaybePromise { + return set( + LibraryIndexedDBAdapter.key, + data, + LibraryIndexedDBAdapter.store, + ); + } +} + +/** LS Adapter used only for migrating LS library data + * to indexedDB */ +export class LibraryLocalStorageMigrationAdapter { + static load() { + const LSData = localStorage.getItem( + STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY, + ); + if (LSData != null) { + const libraryItems: ImportedDataState["libraryItems"] = + JSON.parse(LSData); + if (libraryItems) { + return { libraryItems }; + } + } + return null; + } + static clear() { + localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); + } +} diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index 7507cf894..b0777d6d9 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -1,6 +1,7 @@ import { ExcalidrawElement, FileId, + OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { getSceneVersion } from "../../packages/excalidraw/element"; import Portal from "../collab/Portal"; @@ -18,9 +19,13 @@ import { decryptData, } from "../../packages/excalidraw/data/encryption"; import { MIME_TYPES } from "../../packages/excalidraw/constants"; -import { reconcileElements } from "../collab/reconciliation"; import { getSyncableElements, SyncableExcalidrawElement } from "."; import { ResolutionType } from "../../packages/excalidraw/utility-types"; +import type { Socket } from "socket.io-client"; +import { + RemoteExcalidrawElement, + reconcileElements, +} from "../../packages/excalidraw/data/reconcile"; // private // ----------------------------------------------------------------------------- @@ -138,12 +143,12 @@ const decryptElements = async ( }; class FirebaseSceneVersionCache { - private static cache = new WeakMap(); - static get = (socket: SocketIOClient.Socket) => { + private static cache = new WeakMap(); + static get = (socket: Socket) => { return FirebaseSceneVersionCache.cache.get(socket); }; static set = ( - socket: SocketIOClient.Socket, + socket: Socket, elements: readonly SyncableExcalidrawElement[], ) => { FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements)); @@ -229,7 +234,7 @@ export const saveToFirebase = async ( !socket || isSavedToFirebase(portal, elements) ) { - return false; + return null; } const firebase = await loadFirestore(); @@ -237,56 +242,59 @@ export const saveToFirebase = async ( const docRef = firestore.collection("scenes").doc(roomId); - const savedData = await firestore.runTransaction(async (transaction) => { + const storedScene = await firestore.runTransaction(async (transaction) => { const snapshot = await transaction.get(docRef); if (!snapshot.exists) { - const sceneDocument = await createFirebaseSceneDocument( + const storedScene = await createFirebaseSceneDocument( firebase, elements, roomKey, ); - transaction.set(docRef, sceneDocument); + transaction.set(docRef, storedScene); - return { - elements, - reconciledElements: null, - }; + return storedScene; } - const prevDocData = snapshot.data() as FirebaseStoredScene; - const prevElements = getSyncableElements( - await decryptElements(prevDocData, roomKey), + const prevStoredScene = snapshot.data() as FirebaseStoredScene; + const prevStoredElements = getSyncableElements( + restoreElements(await decryptElements(prevStoredScene, roomKey), null), ); - const reconciledElements = getSyncableElements( - reconcileElements(elements, prevElements, appState), + reconcileElements( + elements, + prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[], + appState, + ), ); - const sceneDocument = await createFirebaseSceneDocument( + const storedScene = await createFirebaseSceneDocument( firebase, reconciledElements, roomKey, ); - transaction.update(docRef, sceneDocument); - return { - elements, - reconciledElements, - }; + transaction.update(docRef, storedScene); + + // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime + return storedScene; }); - FirebaseSceneVersionCache.set(socket, savedData.elements); + const storedElements = getSyncableElements( + restoreElements(await decryptElements(storedScene, roomKey), null), + ); - return { reconciledElements: savedData.reconciledElements }; + FirebaseSceneVersionCache.set(socket, storedElements); + + return storedElements; }; export const loadFromFirebase = async ( roomId: string, roomKey: string, - socket: SocketIOClient.Socket | null, -): Promise => { + socket: Socket | null, +): Promise => { const firebase = await loadFirestore(); const db = firebase.firestore(); @@ -297,14 +305,14 @@ export const loadFromFirebase = async ( } const storedScene = doc.data() as FirebaseStoredScene; const elements = getSyncableElements( - await decryptElements(storedScene, roomKey), + restoreElements(await decryptElements(storedScene, roomKey), null), ); if (socket) { FirebaseSceneVersionCache.set(socket, elements); } - return restoreElements(elements, null); + return elements; }; export const loadFilesFromFirebase = async ( diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 6bab98332..10c97fd20 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -10,34 +10,38 @@ import { import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { restore } from "../../packages/excalidraw/data/restore"; import { ImportedDataState } from "../../packages/excalidraw/data/types"; +import { SceneBounds } from "../../packages/excalidraw/element/bounds"; import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { ExcalidrawElement, FileId, + OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { t } from "../../packages/excalidraw/i18n"; import { AppState, BinaryFileData, BinaryFiles, + SocketId, UserIdleState, } from "../../packages/excalidraw/types"; +import { MakeBrand } from "../../packages/excalidraw/utility-types"; import { bytesToHexString } from "../../packages/excalidraw/utils"; import { DELETED_ELEMENT_TIMEOUT, FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES, + WS_SUBTYPES, } from "../app_constants"; import { encodeFilesForUpload } from "./FileManager"; import { saveFilesToFirebase } from "./firebase"; -export type SyncableExcalidrawElement = ExcalidrawElement & { - _brand: "SyncableExcalidrawElement"; -}; +export type SyncableExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"SyncableExcalidrawElement">; export const isSyncableElement = ( - element: ExcalidrawElement, + element: OrderedExcalidrawElement, ): element is SyncableExcalidrawElement => { if (element.isDeleted) { if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) { @@ -48,7 +52,9 @@ export const isSyncableElement = ( return !isInvisiblySmallElement(element); }; -export const getSyncableElements = (elements: readonly ExcalidrawElement[]) => +export const getSyncableElements = ( + elements: readonly OrderedExcalidrawElement[], +) => elements.filter((element) => isSyncableElement(element), ) as SyncableExcalidrawElement[]; @@ -62,67 +68,49 @@ const generateRoomId = async () => { return bytesToHexString(buffer); }; -/** - * Right now the reason why we resolve connection params (url, polling...) - * from upstream is to allow changing the params immediately when needed without - * having to wait for clients to update the SW. - * - * If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks) - */ -export const getCollabServer = async (): Promise<{ - url: string; - polling: boolean; -}> => { - if (import.meta.env.VITE_APP_WS_SERVER_URL) { - return { - url: import.meta.env.VITE_APP_WS_SERVER_URL, - polling: true, - }; - } - - try { - const resp = await fetch( - `${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`, - ); - return await resp.json(); - } catch (error) { - console.error(error); - throw new Error(t("errors.cannotResolveCollabServer")); - } -}; - export type EncryptedData = { data: ArrayBuffer; iv: Uint8Array; }; export type SocketUpdateDataSource = { + INVALID_RESPONSE: { + type: WS_SUBTYPES.INVALID_RESPONSE; + }; SCENE_INIT: { - type: "SCENE_INIT"; + type: WS_SUBTYPES.INIT; payload: { elements: readonly ExcalidrawElement[]; }; }; SCENE_UPDATE: { - type: "SCENE_UPDATE"; + type: WS_SUBTYPES.UPDATE; payload: { elements: readonly ExcalidrawElement[]; }; }; MOUSE_LOCATION: { - type: "MOUSE_LOCATION"; + type: WS_SUBTYPES.MOUSE_LOCATION; payload: { - socketId: string; + socketId: SocketId; pointer: { x: number; y: number; tool: "pointer" | "laser" }; button: "down" | "up"; selectedElementIds: AppState["selectedElementIds"]; username: string; }; }; - IDLE_STATUS: { - type: "IDLE_STATUS"; + USER_VISIBLE_SCENE_BOUNDS: { + type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS; payload: { - socketId: string; + socketId: SocketId; + username: string; + sceneBounds: SceneBounds; + }; + }; + IDLE_STATUS: { + type: WS_SUBTYPES.IDLE_STATUS; + payload: { + socketId: SocketId; userState: UserIdleState; username: string; }; @@ -130,10 +118,7 @@ export type SocketUpdateDataSource = { }; export type SocketUpdateDataIncoming = - | SocketUpdateDataSource[keyof SocketUpdateDataSource] - | { - type: "INVALID_RESPONSE"; - }; + SocketUpdateDataSource[keyof SocketUpdateDataSource]; export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & { diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index ce4258f4e..0a6a16081 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -6,7 +6,6 @@ import { } from "../../packages/excalidraw/appState"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { STORAGE_KEYS } from "../app_constants"; -import { ImportedDataState } from "../../packages/excalidraw/data/types"; export const saveUsernameToLocalStorage = (username: string) => { try { @@ -88,28 +87,13 @@ export const getTotalStorageSize = () => { try { const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); - const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); const appStateSize = appState?.length || 0; const collabSize = collab?.length || 0; - const librarySize = library?.length || 0; - return appStateSize + collabSize + librarySize + getElementsStorageSize(); + return appStateSize + collabSize + getElementsStorageSize(); } catch (error: any) { console.error(error); return 0; } }; - -export const getLibraryItemsFromStorage = () => { - try { - const libraryItems: ImportedDataState["libraryItems"] = JSON.parse( - localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, - ); - - return libraryItems || []; - } catch (error) { - console.error(error); - return []; - } -}; diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index c11d9ab68..db5bd6457 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -64,12 +64,30 @@ - <% if ("%PROD%" === "true") { %> + <% if (typeof PROD != 'undefined' && PROD == true) { %> - <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %> - <% } %> diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index d7ab79836..d5cc4770c 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -4,6 +4,13 @@ &.theme--dark { --color-primary-contrast-offset: #726dff; // to offset Chubb illusion } + + .top-right-ui { + display: flex; + justify-content: center; + align-items: flex-start; + } + .footer-center { justify-content: flex-end; margin-top: auto; @@ -31,7 +38,7 @@ background-color: #ecfdf5; color: #064e3c; } - &.ExcalidrawPlus { + &.highlighted { color: var(--color-promo); } } diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index cc1335818..8b82d01ad 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -1,4 +1,8 @@ { + "name": "excalidraw-app", + "version": "1.0.0", + "private": true, + "homepage": ".", "browserslist": { "production": [ ">0.2%", @@ -22,19 +26,15 @@ "node": ">=18.0.0" }, "dependencies": { - "packages/excalidraw": "*" + "vite-plugin-html": "3.2.2" }, - "homepage": ".", - "name": "excalidraw-app", "prettier": "@excalidraw/prettier-config", - "private": true, "scripts": { "build-node": "node ./scripts/build-node.js", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", - "install:deps": "yarn install --frozen-lockfile && yarn --cwd ../", "start": "yarn && vite", "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "build:preview": "yarn build && vite preview --port 5000" diff --git a/excalidraw-app/collab/RoomDialog.scss b/excalidraw-app/share/ShareDialog.scss similarity index 80% rename from excalidraw-app/collab/RoomDialog.scss rename to excalidraw-app/share/ShareDialog.scss index 93885e647..87fde8491 100644 --- a/excalidraw-app/collab/RoomDialog.scss +++ b/excalidraw-app/share/ShareDialog.scss @@ -1,7 +1,7 @@ -@import "../../packages/excalidraw/css/variables.module"; +@import "../../packages/excalidraw/css/variables.module.scss"; .excalidraw { - .RoomDialog { + .ShareDialog { display: flex; flex-direction: column; gap: 1.5rem; @@ -10,8 +10,25 @@ height: calc(100vh - 5rem); } + &__separator { + border-top: 1px solid var(--dialog-border-color); + text-align: center; + display: flex; + justify-content: center; + align-items: center; + margin-top: 1em; + + span { + background: var(--island-bg-color); + padding: 0px 0.75rem; + transform: translateY(-1ch); + display: inline-flex; + line-height: 1; + } + } + &__popover { - @keyframes RoomDialog__popover__scaleIn { + @keyframes ShareDialog__popover__scaleIn { from { opacity: 0; } @@ -50,10 +67,10 @@ } transform-origin: var(--radix-popover-content-transform-origin); - animation: RoomDialog__popover__scaleIn 150ms ease-out; + animation: ShareDialog__popover__scaleIn 150ms ease-out; } - &__inactive { + &__picker { font-family: "Assistant"; &__illustration { @@ -95,7 +112,7 @@ } } - &__start_session { + &__button { display: flex; align-items: center; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx new file mode 100644 index 000000000..61df3a35f --- /dev/null +++ b/excalidraw-app/share/ShareDialog.tsx @@ -0,0 +1,299 @@ +import { useEffect, useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; +import { trackEvent } from "../../packages/excalidraw/analytics"; +import { getFrame } from "../../packages/excalidraw/utils"; +import { useI18n } from "../../packages/excalidraw/i18n"; +import { KEYS } from "../../packages/excalidraw/keys"; +import { Dialog } from "../../packages/excalidraw/components/Dialog"; +import { + copyIcon, + LinkIcon, + playerPlayIcon, + playerStopFilledIcon, + share, + shareIOS, + shareWindows, + tablerCheckIcon, +} from "../../packages/excalidraw/components/icons"; +import { TextField } from "../../packages/excalidraw/components/TextField"; +import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; +import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab"; +import { atom, useAtom, useAtomValue } from "jotai"; + +import "./ShareDialog.scss"; +import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; + +type OnExportToBackend = () => void; +type ShareDialogType = "share" | "collaborationOnly"; + +export const shareDialogStateAtom = atom< + { isOpen: false } | { isOpen: true; type: ShareDialogType } +>({ isOpen: false }); + +const getShareIcon = () => { + const navigator = window.navigator as any; + const isAppleBrowser = /Apple/.test(navigator.vendor); + const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1; + + if (isAppleBrowser) { + return shareIOS; + } else if (isWindowsBrowser) { + return shareWindows; + } + + return share; +}; + +export type ShareDialogProps = { + collabAPI: CollabAPI | null; + handleClose: () => void; + onExportToBackend: OnExportToBackend; + type: ShareDialogType; +}; + +const ActiveRoomDialog = ({ + collabAPI, + activeRoomLink, + handleClose, +}: { + collabAPI: CollabAPI; + activeRoomLink: string; + handleClose: () => void; +}) => { + const { t } = useI18n(); + const [justCopied, setJustCopied] = useState(false); + const timerRef = useRef(0); + const ref = useRef(null); + const isShareSupported = "share" in navigator; + + const copyRoomLink = async () => { + try { + await copyTextToSystemClipboard(activeRoomLink); + } catch (e) { + collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed")); + } + + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); + + ref.current?.select(); + }; + + const shareRoomLink = async () => { + try { + await navigator.share({ + title: t("roomDialog.shareTitle"), + text: t("roomDialog.shareTitle"), + url: activeRoomLink, + }); + } catch (error: any) { + // Just ignore. + } + }; + + return ( + <> +

+ {t("labels.liveCollaboration").replace(/\./g, "")} +

+ event.key === KEYS.ENTER && handleClose()} + /> +
+ + {isShareSupported && ( + + )} + + + + + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + className="ShareDialog__popover" + side="top" + align="end" + sideOffset={5.5} + > + {tablerCheckIcon} copied + + +
+
+

+ + {t("roomDialog.desc_privacy")} +

+

{t("roomDialog.desc_exitSession")}

+
+ +
+ { + trackEvent("share", "room closed"); + collabAPI.stopCollaboration(); + if (!collabAPI.isCollaborating()) { + handleClose(); + } + }} + /> +
+ + ); +}; + +const ShareDialogPicker = (props: ShareDialogProps) => { + const { t } = useI18n(); + + const { collabAPI } = props; + + const startCollabJSX = collabAPI ? ( + <> +
+ {t("labels.liveCollaboration").replace(/\./g, "")} +
+ +
+
{t("roomDialog.desc_intro")}
+ {t("roomDialog.desc_privacy")} +
+ +
+ { + trackEvent("share", "room creation", `ui (${getFrame()})`); + collabAPI.startCollaboration(null); + }} + /> +
+ + {props.type === "share" && ( +
+ {t("shareDialog.or")} +
+ )} + + ) : null; + + return ( + <> + {startCollabJSX} + + {props.type === "share" && ( + <> +
+ {t("exportDialog.link_title")} +
+
+ {t("exportDialog.link_details")} +
+ +
+ { + await props.onExportToBackend(); + props.handleClose(); + }} + /> +
+ + )} + + ); +}; + +const ShareDialogInner = (props: ShareDialogProps) => { + const activeRoomLink = useAtomValue(activeRoomLinkAtom); + + return ( + +
+ {props.collabAPI && activeRoomLink ? ( + + ) : ( + + )} +
+
+ ); +}; + +export const ShareDialog = (props: { + collabAPI: CollabAPI | null; + onExportToBackend: OnExportToBackend; +}) => { + const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom); + + const { openDialog } = useUIAppState(); + + useEffect(() => { + if (openDialog) { + setShareDialogState({ isOpen: false }); + } + }, [openDialog, setShareDialogState]); + + if (!shareDialogState.isOpen) { + return null; + } + + return ( + setShareDialogState({ isOpen: false })} + collabAPI={props.collabAPI} + onExportToBackend={props.onExportToBackend} + type={shareDialogState.type} + /> + ); +}; diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap index ad0c9f0f1..b657dbb54 100644 --- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -216,32 +216,23 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u stroke-width="2" viewBox="0 0 24 24" > - + - - + @@ -249,7 +240,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
- Try Excalidraw Plus! + Sign up
diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 455316aed..2e2f1332a 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -7,6 +7,8 @@ import { import ExcalidrawApp from "../App"; import { API } from "../../packages/excalidraw/tests/helpers/api"; import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory"; +import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex"; + const { h } = window; Object.defineProperty(window, "crypto", { @@ -20,17 +22,6 @@ Object.defineProperty(window, "crypto", { }, }); -vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => { - const module = (await importActual()) as any; - return { - __esmodule: true, - ...module, - getCollabServer: vi.fn(() => ({ - url: /* doesn't really matter */ "http://localhost:3002", - })), - }; -}); - vi.mock("../../excalidraw-app/data/firebase.ts", () => { const loadFromFirebase = async () => null; const saveToFirebase = () => {}; @@ -72,14 +63,14 @@ describe("collaboration", () => { await render(); // To update the scene with deleted elements before starting collab updateSceneData({ - elements: [ + elements: syncInvalidIndices([ API.createElement({ type: "rectangle", id: "A" }), API.createElement({ type: "rectangle", id: "B", isDeleted: true, }), - ], + ]), }); await waitFor(() => { expect(h.elements).toEqual([ diff --git a/excalidraw-app/tests/reconciliation.test.ts b/excalidraw-app/tests/reconciliation.test.ts deleted file mode 100644 index 8e395474c..000000000 --- a/excalidraw-app/tests/reconciliation.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { expect } from "chai"; -import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; -import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; -import { - BroadcastedExcalidrawElement, - ReconciledElements, - reconcileElements, -} from "../../excalidraw-app/collab/reconciliation"; -import { randomInteger } from "../../packages/excalidraw/random"; -import { AppState } from "../../packages/excalidraw/types"; -import { cloneJSON } from "../../packages/excalidraw/utils"; - -type Id = string; -type ElementLike = { - id: string; - version: number; - versionNonce: number; - [PRECEDING_ELEMENT_KEY]?: string | null; -}; - -type Cache = Record; - -const createElement = (opts: { uid: string } | ElementLike) => { - let uid: string; - let id: string; - let version: number | null; - let parent: string | null = null; - let versionNonce: number | null = null; - if ("uid" in opts) { - const match = opts.uid.match( - /^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/, - )!; - parent = match[1]; - id = match[2]; - version = match[3] ? parseInt(match[3]) : null; - uid = version ? `${id}:${version}` : id; - } else { - ({ id, version, versionNonce } = opts); - parent = parent || null; - uid = id; - } - return { - uid, - id, - version, - versionNonce: versionNonce || randomInteger(), - [PRECEDING_ELEMENT_KEY]: parent || null, - }; -}; - -const idsToElements = ( - ids: (Id | ElementLike)[], - cache: Cache = {}, -): readonly ExcalidrawElement[] => { - return ids.reduce((acc, _uid, idx) => { - const { - uid, - id, - version, - [PRECEDING_ELEMENT_KEY]: parent, - versionNonce, - } = createElement(typeof _uid === "string" ? { uid: _uid } : _uid); - const cached = cache[uid]; - const elem = { - id, - version: version ?? 0, - versionNonce, - ...cached, - [PRECEDING_ELEMENT_KEY]: parent, - } as BroadcastedExcalidrawElement; - // @ts-ignore - cache[uid] = elem; - acc.push(elem); - return acc; - }, [] as ExcalidrawElement[]); -}; - -const addParents = (elements: BroadcastedExcalidrawElement[]) => { - return elements.map((el, idx, els) => { - el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^"; - return el; - }); -}; - -const cleanElements = (elements: ReconciledElements) => { - return elements.map((el) => { - // @ts-ignore - delete el[PRECEDING_ELEMENT_KEY]; - // @ts-ignore - delete el.next; - // @ts-ignore - delete el.prev; - return el; - }); -}; - -const test = ( - local: (Id | ElementLike)[], - remote: (Id | ElementLike)[], - target: U[], - bidirectional = true, -) => { - const cache: Cache = {}; - const _local = idsToElements(local, cache); - const _remote = idsToElements(remote, cache); - const _target = target.map((uid) => { - const [, id, source] = uid.match(/^(\w+):([LR])$/)!; - return (source === "L" ? _local : _remote).find((e) => e.id === id)!; - }) as any as ReconciledElements; - const remoteReconciled = reconcileElements(_local, _remote, {} as AppState); - expect(target.length).equal(remoteReconciled.length); - expect(cleanElements(remoteReconciled)).deep.equal( - cleanElements(_target), - "remote reconciliation", - ); - - const __local = cleanElements(cloneJSON(_remote) as ReconciledElements); - const __remote = addParents(cleanElements(cloneJSON(remoteReconciled))); - if (bidirectional) { - try { - expect( - cleanElements( - reconcileElements( - cloneJSON(__local), - cloneJSON(__remote), - {} as AppState, - ), - ), - ).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation"); - } catch (error: any) { - console.error("local original", __local); - console.error("remote reconciled", __remote); - throw error; - } - } -}; - -export const findIndex = ( - array: readonly T[], - cb: (element: T, index: number, array: readonly T[]) => boolean, - fromIndex: number = 0, -) => { - if (fromIndex < 0) { - fromIndex = array.length + fromIndex; - } - fromIndex = Math.min(array.length, Math.max(fromIndex, 0)); - let index = fromIndex - 1; - while (++index < array.length) { - if (cb(array[index], index, array)) { - return index; - } - } - return -1; -}; - -// ----------------------------------------------------------------------------- - -describe("elements reconciliation", () => { - it("reconcileElements()", () => { - // ------------------------------------------------------------------------- - // - // in following tests, we pass: - // (1) an array of local elements and their version (:1, :2...) - // (2) an array of remote elements and their version (:1, :2...) - // (3) expected reconciled elements - // - // in the reconciled array: - // :L means local element was resolved - // :R means remote element was resolved - // - // if a remote element is prefixed with parentheses, the enclosed string: - // (^) means the element is the first element in the array - // () means the element is preceded by element - // - // if versions are missing, it defaults to version 0 - // ------------------------------------------------------------------------- - - // non-annotated elements - // ------------------------------------------------------------------------- - // usually when we sync elements they should always be annotated with - // their (preceding elements) parents, but let's test a couple of cases when - // they're not for whatever reason (remote clients are on older version...), - // in which case the first synced element either replaces existing element - // or is pushed at the end of the array - - test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]); - test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]); - test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]); - test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]); - test(["A", "B"], ["A:1"], ["A:R", "B:L"]); - test(["A"], ["A", "B"], ["A:L", "B:R"]); - test(["A"], ["A:1", "B"], ["A:R", "B:R"]); - test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]); - test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]); - test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]); - test(["A"], ["A:1"], ["A:R"]); - - // C isn't added to the end because it follows B (even if B was resolved - // to local version) - test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]); - - // some of the following tests are kinda arbitrary and they're less - // likely to happen in real-world cases - - test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]); - test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]); - test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]); - test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]); - test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]); - test( - ["A:2", "B:2", "C"], - ["D", "B:1", "A:3"], - ["B:L", "A:R", "C:L", "D:R"], - ); - test( - ["A:2", "B:2", "C"], - ["D", "B:2", "A:3", "C"], - ["D:R", "B:L", "A:R", "C:L"], - ); - test( - ["A", "B", "C", "D", "E", "F"], - ["A", "B:2", "X", "E:2", "F", "Y"], - ["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"], - ); - - // annotated elements - // ------------------------------------------------------------------------- - - test( - ["A", "B", "C"], - ["(B)X", "(A)Y", "(Y)Z"], - ["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"], - ); - - test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]); - test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]); - - test( - ["A", "B"], - ["(A)C", "(^)D", "F"], - ["A:L", "C:R", "D:R", "F:R", "B:L"], - ); - - test( - ["A", "B", "C", "D"], - ["(B)C:1", "B", "D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test( - ["A", "B", "C"], - ["(^)X", "(A)Y", "(B)Z"], - ["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"], - ); - - test( - ["B", "A", "C"], - ["(^)X", "(A)Y", "(B)Z"], - ["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"], - ); - - test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]); - - test( - ["A", "B", "C", "D", "E"], - ["(A)X", "(C)Y", "(D)Z"], - ["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"], - ); - - test( - ["X", "Y", "Z"], - ["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"], - ["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"], - ); - - test( - ["A", "B", "C", "D", "E"], - ["(C)X", "(A)Y", "(D)E:1"], - ["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"], - ); - - test( - ["C:1", "B", "D:1"], - ["A", "B", "C:1", "D:1"], - ["A:R", "B:L", "C:L", "D:L"], - ); - - test( - ["A", "B", "C", "D"], - ["(A)C:1", "(C)B", "(B)D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test( - ["A", "B", "C", "D"], - ["(A)C:1", "(C)B", "(B)D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test( - ["C:1", "B", "D:1"], - ["(^)A", "(A)B", "(B)C:2", "(C)D:1"], - ["A:R", "B:L", "C:R", "D:L"], - ); - - test( - ["A", "B", "C", "D"], - ["(C)X", "(B)Y", "(A)Z"], - ["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"], - ); - - test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]); - test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]); - test( - ["A", "B", "C", "D"], - ["(A)C:1", "B", "D:1"], - ["A:L", "C:R", "B:L", "D:R"], - ); - - test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]); - test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]); - - test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]); - test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]); - test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]); - test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]); - test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]); - test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]); - test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]); - test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); - test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]); - }); - - it("test identical elements reconciliation", () => { - const testIdentical = ( - local: ElementLike[], - remote: ElementLike[], - expected: Id[], - ) => { - const ret = reconcileElements( - local as any as ExcalidrawElement[], - remote as any as ExcalidrawElement[], - {} as AppState, - ); - - if (new Set(ret.map((x) => x.id)).size !== ret.length) { - throw new Error("reconcileElements: duplicate elements found"); - } - - expect(ret.map((x) => x.id)).to.deep.equal(expected); - }; - - // identical id/version/versionNonce - // ------------------------------------------------------------------------- - - testIdentical( - [{ id: "A", version: 1, versionNonce: 1 }], - [{ id: "A", version: 1, versionNonce: 1 }], - ["A"], - ); - testIdentical( - [ - { id: "A", version: 1, versionNonce: 1 }, - { id: "B", version: 1, versionNonce: 1 }, - ], - [ - { id: "B", version: 1, versionNonce: 1 }, - { id: "A", version: 1, versionNonce: 1 }, - ], - ["B", "A"], - ); - testIdentical( - [ - { id: "A", version: 1, versionNonce: 1 }, - { id: "B", version: 1, versionNonce: 1 }, - ], - [ - { id: "B", version: 1, versionNonce: 1 }, - { id: "A", version: 1, versionNonce: 1 }, - ], - ["B", "A"], - ); - - // actually identical (arrays and element objects) - // ------------------------------------------------------------------------- - - const elements1 = [ - { - id: "A", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }, - { - id: "B", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }, - ]; - - testIdentical(elements1, elements1, ["A", "B"]); - testIdentical(elements1, elements1.slice(), ["A", "B"]); - testIdentical(elements1.slice(), elements1, ["A", "B"]); - testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]); - - const el1 = { - id: "A", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }; - const el2 = { - id: "B", - version: 1, - versionNonce: 1, - [PRECEDING_ELEMENT_KEY]: null, - }; - testIdentical([el1, el2], [el2, el1], ["A", "B"]); - }); -}); diff --git a/excalidraw-app/useHandleAppTheme.ts b/excalidraw-app/useHandleAppTheme.ts new file mode 100644 index 000000000..184f5d756 --- /dev/null +++ b/excalidraw-app/useHandleAppTheme.ts @@ -0,0 +1,70 @@ +import { atom, useAtom } from "jotai"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { THEME } from "../packages/excalidraw"; +import { EVENT } from "../packages/excalidraw/constants"; +import { Theme } from "../packages/excalidraw/element/types"; +import { CODES, KEYS } from "../packages/excalidraw/keys"; +import { STORAGE_KEYS } from "./app_constants"; + +export const appThemeAtom = atom( + (localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as + | Theme + | "system" + | null) || THEME.LIGHT, +); + +const getDarkThemeMediaQuery = (): MediaQueryList | undefined => + window.matchMedia?.("(prefers-color-scheme: dark)"); + +export const useHandleAppTheme = () => { + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const [editorTheme, setEditorTheme] = useState(THEME.LIGHT); + + useEffect(() => { + const mediaQuery = getDarkThemeMediaQuery(); + + const handleChange = (e: MediaQueryListEvent) => { + setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT); + }; + + if (appTheme === "system") { + mediaQuery?.addEventListener("change", handleChange); + } + + const handleKeydown = (event: KeyboardEvent) => { + if ( + !event[KEYS.CTRL_OR_CMD] && + event.altKey && + event.shiftKey && + event.code === CODES.D + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK); + } + }; + + document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true }); + + return () => { + mediaQuery?.removeEventListener("change", handleChange); + document.removeEventListener(EVENT.KEYDOWN, handleKeydown, { + capture: true, + }); + }; + }, [appTheme, editorTheme, setAppTheme]); + + useLayoutEffect(() => { + localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); + + if (appTheme === "system") { + setEditorTheme( + getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT, + ); + } else { + setEditorTheme(appTheme); + } + }, [appTheme]); + + return { editorTheme }; +}; diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index b3714e81e..39417de36 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr"; import { ViteEjsPlugin } from "vite-plugin-ejs"; import { VitePWA } from "vite-plugin-pwa"; import checker from "vite-plugin-checker"; +import { createHtmlPlugin } from "vite-plugin-html"; // To load .env.local variables const envVars = loadEnv("", `../`); @@ -189,6 +190,9 @@ export default defineConfig({ ], }, }), + createHtmlPlugin({ + minify: true, + }), ], publicDir: "../public", }); diff --git a/package.json b/package.json index 4b3ebb3e2..350f1469f 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,22 @@ "name": "excalidraw-monorepo", "workspaces": [ "excalidraw-app", - "packages/excalidraw" + "packages/excalidraw", + "packages/utils", + "examples/excalidraw", + "examples/excalidraw/*" ], "dependencies": { "@excalidraw/random-username": "1.0.0", "@sentry/browser": "6.2.5", "@sentry/integrations": "6.2.5", - "jotai": "1.13.1", "firebase": "8.3.3", "i18next-browser-languagedetector": "6.1.4", "idb-keyval": "6.0.3", - "socket.io-client": "2.3.1", + "jotai": "1.13.1", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "socket.io-client": "4.7.2" }, "devDependencies": { "@excalidraw/eslint-config": "1.0.3", @@ -25,7 +28,7 @@ "@types/lodash.throttle": "4.1.7", "@types/react": "18.0.15", "@types/react-dom": "18.0.6", - "@types/socket.io-client": "1.4.36", + "@types/socket.io-client": "3.0.0", "@vitejs/plugin-react": "3.1.0", "@vitest/coverage-v8": "0.33.0", "@vitest/ui": "0.32.2", @@ -42,7 +45,7 @@ "prettier": "2.6.2", "rewire": "6.0.0", "typescript": "4.9.4", - "vite": "5.0.6", + "vite": "5.0.12", "vite-plugin-checker": "0.6.1", "vite-plugin-ejs": "1.7.0", "vite-plugin-pwa": "0.17.4", @@ -64,7 +67,6 @@ "fix:code": "yarn test:code --fix", "fix:other": "yarn prettier --write", "fix": "yarn fix:other && yarn fix:code", - "install:deps": "yarn install --frozen-lockfile && yarn --frozen-lockfile --cwd ./excalidraw-app", "locales-coverage": "node scripts/build-locales-coverage.js", "locales-coverage:description": "node scripts/locales-coverage-description.js", "prepare": "husky install", diff --git a/packages/excalidraw/.gitignore b/packages/excalidraw/.gitignore index 2f39e3c37..971fcb7d3 100644 --- a/packages/excalidraw/.gitignore +++ b/packages/excalidraw/.gitignore @@ -1,4 +1,2 @@ node_modules -dist -example types diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index de8ce7227..b6fcd36fa 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -13,14 +13,67 @@ Please add the latest change on the top under the correct section. ## Unreleased +### Features + +- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) +- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) +- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655) +- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + +- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). + +- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) + +### Fixes + +- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656) + ### Breaking Changes +- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) + +- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. + +- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed + + #### Bundler + + - CSS needs to be imported so you will need to import the css along with the excalidraw component + + ```js + import { Excalidraw } from "@excalidraw/excalidraw"; + import "@excalidraw/excalidraw/index.css"; + ``` + + - The `types` path is updated + + Instead of importing from `@excalidraw/excalidraw/types/`, you will need to import from `@excalidraw/excalidraw/dist/excalidraw` or `@excalidraw/excalidraw/dist/utils` depending on the types you are using. + + However this we will be fixing before stable release, so in case you want to try it out you will need to update the types for now. + + #### Browser + + - Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well. + + ```html + + + ``` + - `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336) -## 0.17.1 (2023-11-28) +## 0.17.3 (2024-02-09) ### Fixes +- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656) + - Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean. ``` @@ -29,6 +82,10 @@ define: { } ``` +- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) + +- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339) + ## Excalidraw Library ### Fixes @@ -231,7 +288,7 @@ define: { - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691) -- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) +- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581). - Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728). - Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index 1686554e4..ccb7fad62 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -58,5 +58,5 @@ export const actionAddToLibrary = register({ }; }); }, - contextItemLabel: "labels.addToLibrary", + label: "labels.addToLibrary", }); diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 137f68ae9..ddcb1415f 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -15,13 +15,13 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; -import { AppClassProperties, AppState } from "../types"; +import { AppClassProperties, AppState, UIAppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; const alignActionsPredicate = ( elements: readonly ExcalidrawElement[], - appState: AppState, + appState: UIAppState, _: unknown, app: AppClassProperties, ) => { @@ -40,8 +40,13 @@ const alignSelectedElements = ( alignment: Alignment, ) => { const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = arrayToMap(elements); - const updatedElements = alignElements(selectedElements, alignment); + const updatedElements = alignElements( + selectedElements, + elementsMap, + alignment, + ); const updatedElementsMap = arrayToMap(updatedElements); @@ -54,6 +59,8 @@ const alignSelectedElements = ( export const actionAlignTop = register({ name: "alignTop", + label: "labels.alignTop", + icon: AlignTopIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -85,6 +92,8 @@ export const actionAlignTop = register({ export const actionAlignBottom = register({ name: "alignBottom", + label: "labels.alignBottom", + icon: AlignBottomIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -116,6 +125,8 @@ export const actionAlignBottom = register({ export const actionAlignLeft = register({ name: "alignLeft", + label: "labels.alignLeft", + icon: AlignLeftIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -147,6 +158,8 @@ export const actionAlignLeft = register({ export const actionAlignRight = register({ name: "alignRight", + label: "labels.alignRight", + icon: AlignRightIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -178,6 +191,8 @@ export const actionAlignRight = register({ export const actionAlignVerticallyCentered = register({ name: "alignVerticallyCentered", + label: "labels.centerVertically", + icon: CenterVerticallyIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { @@ -205,6 +220,8 @@ export const actionAlignVerticallyCentered = register({ export const actionAlignHorizontallyCentered = register({ name: "alignHorizontallyCentered", + label: "labels.centerHorizontally", + icon: CenterHorizontallyIcon, trackEvent: { category: "element" }, predicate: alignActionsPredicate, perform: (elements, appState, _, app) => { diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index b42169544..1fcf80fd0 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -17,7 +17,7 @@ import { getOriginalContainerHeightFromCache, resetOriginalContainerCache, updateOriginalContainerCache, -} from "../element/textWysiwyg"; +} from "../element/containerCache"; import { hasBoundTextElement, isTextBindableContainer, @@ -31,12 +31,13 @@ import { } from "../element/types"; import { AppState } from "../types"; import { Mutable } from "../utility-types"; -import { getFontString } from "../utils"; +import { arrayToMap, getFontString } from "../utils"; import { register } from "./register"; +import { syncMovedIndices } from "../fractionalIndex"; export const actionUnbindText = register({ name: "unbindText", - contextItemLabel: "labels.unbindText", + label: "labels.unbindText", trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -45,10 +46,11 @@ export const actionUnbindText = register({ }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = app.scene.getNonDeletedElementsMap(); selectedElements.forEach((element) => { - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { - const { width, height, baseline } = measureText( + const { width, height } = measureText( boundTextElement.originalText, getFontString(boundTextElement), boundTextElement.lineHeight, @@ -57,12 +59,15 @@ export const actionUnbindText = register({ element.id, ); resetOriginalContainerCache(element.id); - const { x, y } = computeBoundTextPosition(element, boundTextElement); + const { x, y } = computeBoundTextPosition( + element, + boundTextElement, + elementsMap, + ); mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, height, - baseline, text: boundTextElement.originalText, x, y, @@ -87,7 +92,7 @@ export const actionUnbindText = register({ export const actionBindText = register({ name: "bindText", - contextItemLabel: "labels.bindText", + label: "labels.bindText", trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -106,7 +111,10 @@ export const actionBindText = register({ if ( textElement && bindingContainer && - getBoundTextElement(bindingContainer) === null + getBoundTextElement( + bindingContainer, + app.scene.getNonDeletedElementsMap(), + ) === null ) { return true; } @@ -141,7 +149,11 @@ export const actionBindText = register({ }), }); const originalContainerHeight = container.height; - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); // overwritting the cache with original container height so // it can be restored when unbind updateOriginalContainerCache(container.id, originalContainerHeight); @@ -169,6 +181,8 @@ const pushTextAboveContainer = ( (ele) => ele.id === container.id, ); updatedElements.splice(containerIndex + 1, 0, textElement); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + return updatedElements; }; @@ -187,12 +201,14 @@ const pushContainerBelowText = ( (ele) => ele.id === textElement.id, ); updatedElements.splice(textElementIndex, 0, container); + syncMovedIndices(updatedElements, arrayToMap([container, textElement])); + return updatedElements; }; export const actionWrapTextInContainer = register({ name: "wrapTextInContainer", - contextItemLabel: "labels.createContainerFromText", + label: "labels.createContainerFromText", trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); @@ -282,13 +298,18 @@ export const actionWrapTextInContainer = register({ }, false, ); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); updatedElements = pushContainerBelowText( [...updatedElements, container], container, textElement, ); + containerIds[container.id] = true; } } diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index f61f57dbd..90492b321 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -1,5 +1,14 @@ import { ColorPicker } from "../components/ColorPicker/ColorPicker"; -import { ZoomInIcon, ZoomOutIcon } from "../components/icons"; +import { + handIcon, + MoonIcon, + SunIcon, + TrashIcon, + zoomAreaIcon, + ZoomInIcon, + ZoomOutIcon, + ZoomResetIcon, +} from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import { getCommonBounds, getNonDeletedElements } from "../element"; @@ -20,11 +29,13 @@ import { isHandToolActive, } from "../appState"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; -import { Bounds } from "../element/bounds"; +import { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", + label: "labels.canvasBackground", + paletteName: "Change canvas background color", trackEvent: false, predicate: (elements, appState, props, app) => { return ( @@ -59,6 +70,9 @@ export const actionChangeViewBackgroundColor = register({ export const actionClearCanvas = register({ name: "clearCanvas", + label: "labels.clearCanvas", + paletteName: "Clear canvas", + icon: TrashIcon, trackEvent: { category: "canvas" }, predicate: (elements, appState, props, app) => { return ( @@ -95,7 +109,9 @@ export const actionClearCanvas = register({ export const actionZoomIn = register({ name: "zoomIn", + label: "buttons.zoomIn", viewMode: true, + icon: ZoomInIcon, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { return { @@ -109,6 +125,7 @@ export const actionZoomIn = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -132,6 +149,8 @@ export const actionZoomIn = register({ export const actionZoomOut = register({ name: "zoomOut", + label: "buttons.zoomOut", + icon: ZoomOutIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { @@ -146,6 +165,7 @@ export const actionZoomOut = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -169,6 +189,8 @@ export const actionZoomOut = register({ export const actionResetZoom = register({ name: "resetZoom", + label: "buttons.resetZoom", + icon: ZoomResetIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { @@ -183,6 +205,7 @@ export const actionResetZoom = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -208,7 +231,7 @@ export const actionResetZoom = register({ }); const zoomValueToFitBoundsOnViewport = ( - bounds: Bounds, + bounds: SceneBounds, viewportDimensions: { width: number; height: number }, ) => { const [x1, y1, x2, y2] = bounds; @@ -226,22 +249,20 @@ const zoomValueToFitBoundsOnViewport = ( return clampedZoomValueToFitElements as NormalizedZoomValue; }; -export const zoomToFit = ({ - targetElements, +export const zoomToFitBounds = ({ + bounds, appState, fitToViewport = false, viewportZoomFactor = 0.7, }: { - targetElements: readonly ExcalidrawElement[]; + bounds: SceneBounds; appState: Readonly; /** whether to fit content to viewport (beyond >100%) */ fitToViewport: boolean; /** zoom content to cover X of the viewport, when fitToViewport=true */ viewportZoomFactor?: number; }) => { - const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); - - const [x1, y1, x2, y2] = commonBounds; + const [x1, y1, x2, y2] = bounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; @@ -282,7 +303,7 @@ export const zoomToFit = ({ scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; } else { - newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, { + newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { width: appState.width, height: appState.height, }); @@ -311,17 +332,45 @@ export const zoomToFit = ({ }; }; +export const zoomToFit = ({ + targetElements, + appState, + fitToViewport, + viewportZoomFactor, +}: { + targetElements: readonly ExcalidrawElement[]; + appState: Readonly; + /** whether to fit content to viewport (beyond >100%) */ + fitToViewport: boolean; + /** zoom content to cover X of the viewport, when fitToViewport=true */ + viewportZoomFactor?: number; +}) => { + const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); + + return zoomToFitBounds({ + bounds: commonBounds, + appState, + fitToViewport, + viewportZoomFactor, + }); +}; + // Note, this action differs from actionZoomToFitSelection in that it doesn't // zoom beyond 100%. In other words, if the content is smaller than viewport // size, it won't be zoomed in. export const actionZoomToFitSelectionInViewport = register({ name: "zoomToFitSelectionInViewport", + label: "labels.zoomToFitViewport", + icon: zoomAreaIcon, trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ targetElements: selectedElements.length ? selectedElements : elements, - appState, + appState: { + ...appState, + userToFollow: null, + }, fitToViewport: false, }); }, @@ -336,12 +385,17 @@ export const actionZoomToFitSelectionInViewport = register({ export const actionZoomToFitSelection = register({ name: "zoomToFitSelection", + label: "helpDialog.zoomToSelection", + icon: zoomAreaIcon, trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ targetElements: selectedElements.length ? selectedElements : elements, - appState, + appState: { + ...appState, + userToFollow: null, + }, fitToViewport: true, }); }, @@ -355,10 +409,19 @@ export const actionZoomToFitSelection = register({ export const actionZoomToFit = register({ name: "zoomToFit", + label: "helpDialog.zoomToFit", + icon: zoomAreaIcon, viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => - zoomToFit({ targetElements: elements, appState, fitToViewport: false }), + zoomToFit({ + targetElements: elements, + appState: { + ...appState, + userToFollow: null, + }, + fitToViewport: false, + }), keyTest: (event) => event.code === CODES.ONE && event.shiftKey && @@ -368,6 +431,13 @@ export const actionZoomToFit = register({ export const actionToggleTheme = register({ name: "toggleTheme", + label: (_, appState) => { + return appState.theme === THEME.DARK + ? "buttons.lightMode" + : "buttons.darkMode"; + }, + keywords: ["toggle", "dark", "light", "mode", "theme"], + icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon), viewMode: true, trackEvent: { category: "canvas" }, perform: (_, appState, value) => { @@ -388,6 +458,7 @@ export const actionToggleTheme = register({ export const actionToggleEraserTool = register({ name: "toggleEraserTool", + label: "toolBar.eraser", trackEvent: { category: "toolbar" }, perform: (elements, appState) => { let activeTool: AppState["activeTool"]; @@ -422,7 +493,11 @@ export const actionToggleEraserTool = register({ export const actionToggleHandTool = register({ name: "toggleHandTool", + label: "toolBar.hand", + paletteName: "Toggle hand tool", trackEvent: { category: "toolbar" }, + icon: handIcon, + viewMode: false, perform: (elements, appState, _, app) => { let activeTool: AppState["activeTool"]; diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index dadc61013..bb488245c 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -13,9 +13,12 @@ import { exportCanvas, prepareElementsForExport } from "../data/index"; import { isTextElement } from "../element"; import { t } from "../i18n"; import { isFirefox } from "../constants"; +import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; export const actionCopy = register({ name: "copy", + label: "labels.copy", + icon: DuplicateIcon, trackEvent: { category: "element" }, perform: async (elements, appState, event: ClipboardEvent | null, app) => { const elementsToCopy = app.scene.getSelectedElements({ @@ -40,13 +43,13 @@ export const actionCopy = register({ commitToHistory: false, }; }, - contextItemLabel: "labels.copy", // don't supply a shortcut since we handle this conditionally via onCopy event keyTest: undefined, }); export const actionPaste = register({ name: "paste", + label: "labels.paste", trackEvent: { category: "element" }, perform: async (elements, appState, data, app) => { let types; @@ -97,24 +100,26 @@ export const actionPaste = register({ commitToHistory: false, }; }, - contextItemLabel: "labels.paste", // don't supply a shortcut since we handle this conditionally via onCopy event keyTest: undefined, }); export const actionCut = register({ name: "cut", + label: "labels.cut", + icon: cutIcon, trackEvent: { category: "element" }, perform: (elements, appState, event: ClipboardEvent | null, app) => { actionCopy.perform(elements, appState, event, app); - return actionDeleteSelected.perform(elements, appState); + return actionDeleteSelected.perform(elements, appState, null, app); }, - contextItemLabel: "labels.cut", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, }); export const actionCopyAsSvg = register({ name: "copyAsSvg", + label: "labels.copyAsSvg", + icon: svgIcon, trackEvent: { category: "element" }, perform: async (elements, appState, _data, app) => { if (!app.canvas) { @@ -138,6 +143,7 @@ export const actionCopyAsSvg = register({ { ...appState, exportingFrame, + name: app.getName(), }, ); return { @@ -157,11 +163,13 @@ export const actionCopyAsSvg = register({ predicate: (elements) => { return probablySupportsClipboardWriteText && elements.length > 0; }, - contextItemLabel: "labels.copyAsSvg", + keywords: ["svg", "clipboard", "copy"], }); export const actionCopyAsPng = register({ name: "copyAsPng", + label: "labels.copyAsPng", + icon: pngIcon, trackEvent: { category: "element" }, perform: async (elements, appState, _data, app) => { if (!app.canvas) { @@ -184,6 +192,7 @@ export const actionCopyAsPng = register({ await exportCanvas("clipboard", exportedElements, appState, app.files, { ...appState, exportingFrame, + name: app.getName(), }); return { appState: { @@ -215,12 +224,13 @@ export const actionCopyAsPng = register({ predicate: (elements) => { return probablySupportsClipboardBlob && elements.length > 0; }, - contextItemLabel: "labels.copyAsPng", keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, + keywords: ["png", "clipboard", "copy"], }); export const copyText = register({ name: "copyText", + label: "labels.copyText", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements({ @@ -236,7 +246,11 @@ export const copyText = register({ return acc; }, []) .join("\n\n"); - copyTextToSystemClipboard(text); + try { + copyTextToSystemClipboard(text); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return { commitToHistory: false, }; @@ -252,5 +266,5 @@ export const copyText = register({ .some(isTextElement) ); }, - contextItemLabel: "labels.copyText", + keywords: ["text", "clipboard", "copy"], }); diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index de25ed898..602d73725 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -72,8 +72,10 @@ const handleGroupEditingState = ( export const actionDeleteSelected = register({ name: "deleteSelectedElements", + label: "labels.delete", + icon: TrashIcon, trackEvent: { category: "element", action: "delete" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { if (appState.editingLinearElement) { const { elementId, @@ -81,7 +83,8 @@ export const actionDeleteSelected = register({ startBindingElement, endBindingElement, } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const elementsMap = app.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -167,7 +170,6 @@ export const actionDeleteSelected = register({ ), }; }, - contextItemLabel: "labels.delete", keyTest: (event, appState, elements) => (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) && !event[KEYS.CTRL_OR_CMD], diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index bf51bedf4..f3075e5a3 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -32,7 +32,11 @@ const distributeSelectedElements = ( ) => { const selectedElements = app.scene.getSelectedElements(appState); - const updatedElements = distributeElements(selectedElements, distribution); + const updatedElements = distributeElements( + selectedElements, + app.scene.getNonDeletedElementsMap(), + distribution, + ); const updatedElementsMap = arrayToMap(updatedElements); @@ -45,6 +49,7 @@ const distributeSelectedElements = ( export const distributeHorizontally = register({ name: "distributeHorizontally", + label: "labels.distributeHorizontally", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { @@ -75,6 +80,7 @@ export const distributeHorizontally = register({ export const distributeVertically = register({ name: "distributeVertically", + label: "labels.distributeVertically", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index ba079168e..46d021a21 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -31,14 +31,21 @@ import { excludeElementsInFramesFromSelection, getSelectedElements, } from "../scene/selection"; +import { syncMovedIndices } from "../fractionalIndex"; export const actionDuplicateSelection = register({ name: "duplicateSelection", + label: "labels.duplicateSelection", + icon: DuplicateIcon, trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { - const ret = LinearElementEditor.duplicateSelectedPoints(appState); + const ret = LinearElementEditor.duplicateSelectedPoints( + appState, + elementsMap, + ); if (!ret) { return false; @@ -56,7 +63,6 @@ export const actionDuplicateSelection = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.duplicateSelection", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, PanelComponent: ({ elements, appState, updateData }) => ( (); const duplicateAndOffsetElement = (element: ExcalidrawElement) => { const newElement = duplicateElement( @@ -96,6 +103,7 @@ const duplicateElements = ( y: element.y + GRID_SIZE / 2, }, ); + duplicatedElementsMap.set(newElement.id, newElement); oldIdToDuplicatedId.set(element.id, newElement.id); oldElements.push(element); newElements.push(newElement); @@ -139,7 +147,7 @@ const duplicateElements = ( continue; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, arrayToMap(elements)); const isElementAFrameLike = isFrameLikeElement(element); if (idsOfElementsToDuplicate.get(element.id)) { @@ -233,9 +241,10 @@ const duplicateElements = ( } // step (3) - const finalElements = finalElementsReversed.reverse(); + syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements])); + // --------------------------------------------------------------------------- bindTextToShapeAfterDuplication( diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 164240b29..7200dca21 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -1,7 +1,9 @@ +import { LockedIcon, UnlockedIcon } from "../components/icons"; import { newElementWith } from "../element/mutateElement"; import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; import { arrayToMap } from "../utils"; import { register } from "./register"; @@ -10,11 +12,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) => export const actionToggleElementLock = register({ name: "toggleElementLock", + label: (elements, appState, app) => { + const selected = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: false, + }); + if (selected.length === 1 && !isFrameLikeElement(selected[0])) { + return selected[0].locked + ? "labels.elementLock.unlock" + : "labels.elementLock.lock"; + } + + return shouldLock(selected) + ? "labels.elementLock.lockAll" + : "labels.elementLock.unlockAll"; + }, + icon: (appState, elements) => { + const selectedElements = getSelectedElements(elements, appState); + return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon; + }, trackEvent: { category: "element" }, predicate: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); - return !selectedElements.some( - (element) => element.locked && element.frameId, + return ( + selectedElements.length > 0 && + !selectedElements.some((element) => element.locked && element.frameId) ); }, perform: (elements, appState, _, app) => { @@ -47,21 +69,6 @@ export const actionToggleElementLock = register({ commitToHistory: true, }; }, - contextItemLabel: (elements, appState, app) => { - const selected = app.scene.getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - includeBoundTextElement: false, - }); - if (selected.length === 1 && !isFrameLikeElement(selected[0])) { - return selected[0].locked - ? "labels.elementLock.unlock" - : "labels.elementLock.lock"; - } - - return shouldLock(selected) - ? "labels.elementLock.lockAll" - : "labels.elementLock.unlockAll"; - }, keyTest: (event, appState, elements, app) => { return ( event.key.toLocaleLowerCase() === KEYS.L && @@ -77,10 +84,16 @@ export const actionToggleElementLock = register({ export const actionUnlockAllElements = register({ name: "unlockAllElements", + paletteName: "Unlock all elements", trackEvent: { category: "canvas" }, viewMode: false, - predicate: (elements) => { - return elements.some((element) => element.locked); + icon: UnlockedIcon, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 0 && + elements.some((element) => element.locked) + ); }, perform: (elements, appState) => { const lockedElements = elements.filter((el) => el.locked); @@ -101,5 +114,5 @@ export const actionUnlockAllElements = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.elementLock.unlockAll", + label: "labels.elementLock.unlockAll", }); diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 74dff34c8..eaa1d514f 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -1,4 +1,4 @@ -import { questionCircle, saveAs } from "../components/icons"; +import { ExportIcon, questionCircle, saveAs } from "../components/icons"; import { ProjectName } from "../components/ProjectName"; import { ToolButton } from "../components/ToolButton"; import { Tooltip } from "../components/Tooltip"; @@ -22,18 +22,16 @@ import "../components/ToolIcon.scss"; export const actionChangeProjectName = register({ name: "changeProjectName", + label: "labels.fileTitle", trackEvent: false, perform: (_elements, appState, value) => { return { appState: { ...appState, name: value }, commitToHistory: false }; }, - PanelComponent: ({ appState, updateData, appProps, data }) => ( + PanelComponent: ({ appState, updateData, appProps, data, app }) => ( updateData(name)} - isNameEditable={ - typeof appProps.name === "undefined" && !appState.viewModeEnabled - } ignoreFocus={data?.ignoreFocus ?? false} /> ), @@ -41,6 +39,7 @@ export const actionChangeProjectName = register({ export const actionChangeExportScale = register({ name: "changeExportScale", + label: "imageExportDialog.scale", trackEvent: { category: "export", action: "scale" }, perform: (_elements, appState, value) => { return { @@ -90,6 +89,7 @@ export const actionChangeExportScale = register({ export const actionChangeExportBackground = register({ name: "changeExportBackground", + label: "imageExportDialog.label.withBackground", trackEvent: { category: "export", action: "toggleBackground" }, perform: (_elements, appState, value) => { return { @@ -109,6 +109,7 @@ export const actionChangeExportBackground = register({ export const actionChangeExportEmbedScene = register({ name: "changeExportEmbedScene", + label: "imageExportDialog.tooltip.embedScene", trackEvent: { category: "export", action: "embedScene" }, perform: (_elements, appState, value) => { return { @@ -131,6 +132,8 @@ export const actionChangeExportEmbedScene = register({ export const actionSaveToActiveFile = register({ name: "saveToActiveFile", + label: "buttons.save", + icon: ExportIcon, trackEvent: { category: "export" }, predicate: (elements, appState, props, app) => { return ( @@ -144,8 +147,13 @@ export const actionSaveToActiveFile = register({ try { const { fileHandle } = isImageFileHandle(appState.fileHandle) - ? await resaveAsImageWithScene(elements, appState, app.files) - : await saveAsJSON(elements, appState, app.files); + ? await resaveAsImageWithScene( + elements, + appState, + app.files, + app.getName(), + ) + : await saveAsJSON(elements, appState, app.files, app.getName()); return { commitToHistory: false, @@ -179,6 +187,8 @@ export const actionSaveToActiveFile = register({ export const actionSaveFileToDisk = register({ name: "saveFileToDisk", + label: "exportDialog.disk_title", + icon: ExportIcon, viewMode: true, trackEvent: { category: "export" }, perform: async (elements, appState, value, app) => { @@ -190,6 +200,7 @@ export const actionSaveFileToDisk = register({ fileHandle: null, }, app.files, + app.getName(), ); return { commitToHistory: false, @@ -227,6 +238,7 @@ export const actionSaveFileToDisk = register({ export const actionLoadScene = register({ name: "loadScene", + label: "buttons.load", trackEvent: { category: "export" }, predicate: (elements, appState, props, app) => { return ( @@ -264,6 +276,7 @@ export const actionLoadScene = register({ export const actionExportWithDarkMode = register({ name: "exportWithDarkMode", + label: "imageExportDialog.label.darkMode", trackEvent: { category: "export", action: "toggleTheme" }, perform: (_elements, appState, value) => { return { diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index a7c34c5ac..88ff366b6 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,6 +1,6 @@ import { KEYS } from "../keys"; import { isInvisiblySmallElement } from "../element"; -import { updateActiveTool } from "../utils"; +import { arrayToMap, updateActiveTool } from "../utils"; import { ToolButton } from "../components/ToolButton"; import { done } from "../components/icons"; import { t } from "../i18n"; @@ -8,7 +8,6 @@ import { register } from "./register"; import { mutateElement } from "../element/mutateElement"; import { isPathALoop } from "../math"; import { LinearElementEditor } from "../element/linearElementEditor"; -import Scene from "../scene/Scene"; import { maybeBindLinearElement, bindOrUnbindLinearElement, @@ -19,17 +18,17 @@ import { resetCursor } from "../cursor"; export const actionFinalize = register({ name: "finalize", + label: "", trackEvent: false, - perform: ( - elements, - appState, - _, - { interactiveCanvas, focusContainer, scene }, - ) => { + perform: (elements, appState, _, app) => { + const { interactiveCanvas, focusContainer, scene } = app; + + const elementsMap = scene.getNonDeletedElementsMap(); + if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { if (isBindingElement(element)) { @@ -37,6 +36,7 @@ export const actionFinalize = register({ element, startBindingElement, endBindingElement, + elementsMap, ); } return { @@ -125,13 +125,9 @@ export const actionFinalize = register({ const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( multiPointElement, -1, + arrayToMap(elements), ); - maybeBindLinearElement( - multiPointElement, - appState, - Scene.getScene(multiPointElement)!, - { x, y }, - ); + maybeBindLinearElement(multiPointElement, appState, { x, y }, app); } } @@ -186,7 +182,7 @@ export const actionFinalize = register({ // To select the linear element when user has finished mutipoint editing selectedLinearElement: multiPointElement && isLinearElement(multiPointElement) - ? new LinearElementEditor(multiPointElement, scene) + ? new LinearElementEditor(multiPointElement) : appState.selectedLinearElement, pendingImageElementId: null, }, diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 12d5e2e48..d821b200d 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -1,9 +1,13 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { + ExcalidrawElement, + NonDeleted, + NonDeletedSceneElementsMap, +} from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; -import { AppState, PointerDownState } from "../types"; +import { AppClassProperties, AppState } from "../types"; import { arrayToMap } from "../utils"; import { CODES, KEYS } from "../keys"; import { getCommonBoundingBox } from "../element/bounds"; @@ -13,14 +17,23 @@ import { unbindLinearElements, } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; +import { flipHorizontal, flipVertical } from "../components/icons"; export const actionFlipHorizontal = register({ name: "flipHorizontal", + label: "labels.flipHorizontal", + icon: flipHorizontal, trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( - flipSelectedElements(elements, appState, "horizontal"), + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "horizontal", + app, + ), appState, app, ), @@ -29,16 +42,23 @@ export const actionFlipHorizontal = register({ }; }, keyTest: (event) => event.shiftKey && event.code === CODES.H, - contextItemLabel: "labels.flipHorizontal", }); export const actionFlipVertical = register({ name: "flipVertical", + label: "labels.flipVertical", + icon: flipVertical, trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( - flipSelectedElements(elements, appState, "vertical"), + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "vertical", + app, + ), appState, app, ), @@ -48,13 +68,14 @@ export const actionFlipVertical = register({ }, keyTest: (event) => event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD], - contextItemLabel: "labels.flipVertical", }); const flipSelectedElements = ( elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", + app: AppClassProperties, ) => { const selectedElements = getSelectedElements( getNonDeletedElements(elements), @@ -67,8 +88,11 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, + elements, + elementsMap, appState, flipDirection, + app, ); const updatedElementsMap = arrayToMap(updatedElements); @@ -79,24 +103,28 @@ const flipSelectedElements = ( }; const flipElements = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", + app: AppClassProperties, ): ExcalidrawElement[] => { - const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements); + const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); resizeMultipleElements( - { originalElements: arrayToMap(elements) } as PointerDownState, - elements, + elementsMap, + selectedElements, + elementsMap, "nw", true, flipDirection === "horizontal" ? maxX : minX, flipDirection === "horizontal" ? minY : maxY, ); - (isBindingEnabled(appState) - ? bindOrUnbindSelectedElements - : unbindLinearElements)(elements); + isBindingEnabled(appState) + ? bindOrUnbindSelectedElements(selectedElements, app) + : unbindLinearElements(selectedElements, elementsMap); - return elements; + return selectedElements; }; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 4cddb2ac0..019533c59 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -3,13 +3,17 @@ import { ExcalidrawElement } from "../element/types"; import { removeAllElementsFromFrame } from "../frame"; import { getFrameChildren } from "../frame"; import { KEYS } from "../keys"; -import { AppClassProperties, AppState } from "../types"; +import { AppClassProperties, AppState, UIAppState } from "../types"; import { updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { register } from "./register"; import { isFrameLikeElement } from "../element/typeChecks"; +import { frameToolIcon } from "../components/icons"; -const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { +const isSingleFrameSelected = ( + appState: UIAppState, + app: AppClassProperties, +) => { const selectedElements = app.scene.getSelectedElements(appState); return ( @@ -19,6 +23,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { export const actionSelectAllElementsInFrame = register({ name: "selectAllElementsInFrame", + label: "labels.selectAllElementsInFrame", trackEvent: { category: "canvas" }, perform: (elements, appState, _, app) => { const selectedElement = @@ -49,13 +54,13 @@ export const actionSelectAllElementsInFrame = register({ commitToHistory: false, }; }, - contextItemLabel: "labels.selectAllElementsInFrame", predicate: (elements, appState, _, app) => isSingleFrameSelected(appState, app), }); export const actionRemoveAllElementsFromFrame = register({ name: "removeAllElementsFromFrame", + label: "labels.removeAllElementsFromFrame", trackEvent: { category: "history" }, perform: (elements, appState, _, app) => { const selectedElement = @@ -63,11 +68,7 @@ export const actionRemoveAllElementsFromFrame = register({ if (isFrameLikeElement(selectedElement)) { return { - elements: removeAllElementsFromFrame( - elements, - selectedElement, - appState, - ), + elements: removeAllElementsFromFrame(elements, selectedElement), appState: { ...appState, selectedElementIds: { @@ -84,13 +85,13 @@ export const actionRemoveAllElementsFromFrame = register({ commitToHistory: false, }; }, - contextItemLabel: "labels.removeAllElementsFromFrame", predicate: (elements, appState, _, app) => isSingleFrameSelected(appState, app), }); export const actionupdateFrameRendering = register({ name: "updateFrameRendering", + label: "labels.updateFrameRendering", viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => { @@ -106,13 +107,15 @@ export const actionupdateFrameRendering = register({ commitToHistory: false, }; }, - contextItemLabel: "labels.updateFrameRendering", checked: (appState: AppState) => appState.frameRendering.enabled, }); export const actionSetFrameAsActiveTool = register({ name: "setFrameAsActiveTool", + label: "toolBar.frame", trackEvent: { category: "toolbar" }, + icon: frameToolIcon, + viewMode: false, perform: (elements, appState, _, app) => { const nextActiveTool = updateActiveTool(appState, { type: "frame", diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index e6cb05840..cda66ae5a 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -27,6 +27,7 @@ import { removeElementsFromFrame, replaceAllElementsInFrame, } from "../frame"; +import { syncMovedIndices } from "../fractionalIndex"; const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { if (elements.length >= 2) { @@ -61,6 +62,8 @@ const enableActionGroup = ( export const actionGroup = register({ name: "group", + label: "labels.group", + icon: (appState) => , trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements({ @@ -105,10 +108,9 @@ export const actionGroup = register({ const frameElementsMap = groupByFrameLikes(selectedElements); frameElementsMap.forEach((elementsInFrame, frameId) => { - nextElements = removeElementsFromFrame( - nextElements, + removeElementsFromFrame( elementsInFrame, - appState, + app.scene.getNonDeletedElementsMap(), ); }); } @@ -139,11 +141,12 @@ export const actionGroup = register({ .filter( (updatedElement) => !isElementInGroup(updatedElement, newGroupId), ); - nextElements = [ + const reorderedElements = [ ...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup, ]; + syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup)); return { appState: { @@ -154,11 +157,10 @@ export const actionGroup = register({ getNonDeletedElements(nextElements), ), }, - elements: nextElements, + elements: reorderedElements, commitToHistory: true, }; }, - contextItemLabel: "labels.group", predicate: (elements, appState, _, app) => enableActionGroup(elements, appState, app), keyTest: (event) => @@ -178,9 +180,13 @@ export const actionGroup = register({ export const actionUngroup = register({ name: "ungroup", + label: "labels.ungroup", + icon: (appState) => , trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const groupIds = getSelectedGroupIds(appState); + const elementsMap = arrayToMap(elements); + if (groupIds.length === 0) { return { appState, elements, commitToHistory: false }; } @@ -227,9 +233,14 @@ export const actionUngroup = register({ if (frame) { nextElements = replaceAllElementsInFrame( nextElements, - getElementsInResizingFrame(nextElements, frame, appState), + getElementsInResizingFrame( + nextElements, + frame, + appState, + elementsMap, + ), frame, - appState, + app, ); } }); @@ -257,7 +268,6 @@ export const actionUngroup = register({ event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G.toUpperCase(), - contextItemLabel: "labels.ungroup", predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, PanelComponent: ({ elements, appState, updateData }) => ( diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 2e0f4c091..fad459003 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -10,6 +10,7 @@ import { newElementWith } from "../element/mutateElement"; import { fixBindingsAfterDeletion } from "../element/binding"; import { arrayToMap } from "../utils"; import { isWindows } from "../constants"; +import { syncInvalidIndices } from "../fractionalIndex"; const writeData = ( prevElements: readonly ExcalidrawElement[], @@ -48,6 +49,8 @@ const writeData = ( ), ); fixBindingsAfterDeletion(elements, deletedElements); + // TODO: will be replaced in #7348 + syncInvalidIndices(elements); return { elements, @@ -63,7 +66,10 @@ type ActionCreator = (history: History) => Action; export const createUndoAction: ActionCreator = (history) => ({ name: "undo", + label: "buttons.undo", + icon: UndoIcon, trackEvent: { category: "history" }, + viewMode: false, perform: (elements, appState) => writeData(elements, appState, () => history.undoOnce()), keyTest: (event) => @@ -84,7 +90,10 @@ export const createUndoAction: ActionCreator = (history) => ({ export const createRedoAction: ActionCreator = (history) => ({ name: "redo", + label: "buttons.redo", + icon: RedoIcon, trackEvent: { category: "history" }, + viewMode: false, perform: (elements, appState) => writeData(elements, appState, () => history.redoOnce()), keyTest: (event) => diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts index 83611b027..5b76868f6 100644 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ b/packages/excalidraw/actions/actionLinearEditor.ts @@ -1,3 +1,4 @@ +import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { LinearElementEditor } from "../element/linearElementEditor"; import { isLinearElement } from "../element/typeChecks"; import { ExcalidrawLinearElement } from "../element/types"; @@ -5,6 +6,16 @@ import { register } from "./register"; export const actionToggleLinearEditor = register({ name: "toggleLinearEditor", + category: DEFAULT_CATEGORIES.elements, + label: (elements, appState, app) => { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + })[0] as ExcalidrawLinearElement; + return appState.editingLinearElement?.elementId === selectedElement?.id + ? "labels.lineEditor.exit" + : "labels.lineEditor.edit"; + }, trackEvent: { category: "element", }, @@ -24,7 +35,7 @@ export const actionToggleLinearEditor = register({ const editingLinearElement = appState.editingLinearElement?.elementId === selectedElement.id ? null - : new LinearElementEditor(selectedElement, app.scene); + : new LinearElementEditor(selectedElement); return { appState: { ...appState, @@ -33,13 +44,4 @@ export const actionToggleLinearEditor = register({ commitToHistory: false, }; }, - contextItemLabel: (elements, appState, app) => { - const selectedElement = app.scene.getSelectedElements({ - selectedElementIds: appState.selectedElementIds, - includeBoundTextElement: true, - })[0] as ExcalidrawLinearElement; - return appState.editingLinearElement?.elementId === selectedElement.id - ? "labels.lineEditor.exit" - : "labels.lineEditor.edit"; - }, }); diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx new file mode 100644 index 000000000..21e3a4e1a --- /dev/null +++ b/packages/excalidraw/actions/actionLink.tsx @@ -0,0 +1,54 @@ +import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; +import { LinkIcon } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { isEmbeddableElement } from "../element/typeChecks"; +import { t } from "../i18n"; +import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { getShortcutKey } from "../utils"; +import { register } from "./register"; + +export const actionLink = register({ + name: "hyperlink", + label: (elements, appState) => getContextMenuLabel(elements, appState), + icon: LinkIcon, + perform: (elements, appState) => { + if (appState.showHyperlinkPopup === "editor") { + return false; + } + + return { + elements, + appState: { + ...appState, + showHyperlinkPopup: "editor", + openMenu: null, + }, + commitToHistory: true, + }; + }, + trackEvent: { category: "hyperlink", action: "click" }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return selectedElements.length === 1; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + updateData(null)} + selected={selectedElements.length === 1 && !!selectedElements[0].link} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index fa8dcbea7..45a97eeba 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -1,4 +1,4 @@ -import { HamburgerMenuIcon, palette } from "../components/icons"; +import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; import { showSelectedShapeActions, getNonDeletedElements } from "../element"; @@ -7,6 +7,7 @@ import { KEYS } from "../keys"; export const actionToggleCanvasMenu = register({ name: "toggleCanvasMenu", + label: "buttons.menu", trackEvent: { category: "menu" }, perform: (_, appState) => ({ appState: { @@ -28,6 +29,7 @@ export const actionToggleCanvasMenu = register({ export const actionToggleEditMenu = register({ name: "toggleEditMenu", + label: "buttons.edit", trackEvent: { category: "menu" }, perform: (_elements, appState) => ({ appState: { @@ -53,6 +55,8 @@ export const actionToggleEditMenu = register({ export const actionShortcuts = register({ name: "toggleShortcuts", + label: "welcomeScreen.defaults.helpHint", + icon: HelpIconThin, viewMode: true, trackEvent: { category: "menu", action: "toggleHelpDialog" }, perform: (_elements, appState, _, { focusContainer }) => { diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 126e547ae..c60185657 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,48 +1,132 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; -import { centerScrollOn } from "../scene/scroll"; +import { GoToCollaboratorComponentProps } from "../components/UserList"; +import { + eyeIcon, + microphoneIcon, + microphoneMutedIcon, +} from "../components/icons"; +import { t } from "../i18n"; import { Collaborator } from "../types"; import { register } from "./register"; +import clsx from "clsx"; export const actionGoToCollaborator = register({ name: "goToCollaborator", + label: "Go to a collaborator", viewMode: true, trackEvent: { category: "collab" }, - perform: (_elements, appState, value) => { - const point = value as Collaborator["pointer"]; - if (!point) { - return { appState, commitToHistory: false }; + perform: (_elements, appState, collaborator: Collaborator) => { + if ( + !collaborator.socketId || + appState.userToFollow?.socketId === collaborator.socketId || + collaborator.isCurrentUser + ) { + return { + appState: { + ...appState, + userToFollow: null, + }, + commitToHistory: false, + }; } return { appState: { ...appState, - ...centerScrollOn({ - scenePoint: point, - viewportDimensions: { - width: appState.width, - height: appState.height, - }, - zoom: appState.zoom, - }), + userToFollow: { + socketId: collaborator.socketId, + username: collaborator.username || "", + }, // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, commitToHistory: false, }; }, - PanelComponent: ({ updateData, data }) => { - const [clientId, collaborator] = data as [string, Collaborator]; + PanelComponent: ({ updateData, data, appState }) => { + const { socketId, collaborator, withName, isBeingFollowed } = + data as GoToCollaboratorComponentProps; - const background = getClientColor(clientId); + const background = getClientColor(socketId, collaborator); - return ( - updateData(collaborator.pointer)} - name={collaborator.username || ""} - src={collaborator.avatarUrl} - /> + const statusClassNames = clsx({ + "is-followed": isBeingFollowed, + "is-current-user": collaborator.isCurrentUser === true, + "is-speaking": collaborator.isSpeaking, + "is-in-call": collaborator.isInCall, + "is-muted": collaborator.isMuted, + }); + + const statusIconJSX = collaborator.isInCall ? ( + collaborator.isSpeaking ? ( +
+
+
+
+
+ ) : collaborator.isMuted ? ( +
+ {microphoneMutedIcon} +
+ ) : ( +
{microphoneIcon}
+ ) + ) : null; + + return withName ? ( +
updateData(collaborator)} + > + {}} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> +
+ {collaborator.username} +
+
+ {isBeingFollowed && ( +
+ {eyeIcon} +
+ )} + {statusIconJSX} +
+
+ ) : ( +
+ { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( +
+ {statusIconJSX} +
+ )} +
); }, }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 9489970ae..562f04b35 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,4 @@ -import { AppState, Primitive } from "../types"; +import { AppClassProperties, AppState, Primitive } from "../types"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, @@ -49,6 +49,7 @@ import { ArrowheadCircleOutlineIcon, ArrowheadDiamondIcon, ArrowheadDiamondOutlineIcon, + fontSizeIcon, } from "../components/icons"; import { DEFAULT_FONT_FAMILY, @@ -66,7 +67,6 @@ import { import { mutateElement, newElementWith } from "../element/mutateElement"; import { getBoundTextElement, - getContainerElement, getDefaultLineHeight, } from "../element/textElement"; import { @@ -189,6 +189,7 @@ const offsetElementAfterFontResize = ( const changeFontSize = ( elements: readonly ExcalidrawElement[], appState: AppState, + app: AppClassProperties, getNewFontSize: (element: ExcalidrawTextElement) => number, fallbackValue?: ExcalidrawTextElement["fontSize"], ) => { @@ -206,7 +207,11 @@ const changeFontSize = ( let newElement: ExcalidrawTextElement = newElementWith(oldElement, { fontSize: newFontSize, }); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); newElement = offsetElementAfterFontResize(oldElement, newElement); @@ -234,6 +239,7 @@ const changeFontSize = ( export const actionChangeStrokeColor = register({ name: "changeStrokeColor", + label: "labels.stroke", trackEvent: false, perform: (elements, appState, value) => { return { @@ -284,6 +290,7 @@ export const actionChangeStrokeColor = register({ export const actionChangeBackgroundColor = register({ name: "changeBackgroundColor", + label: "labels.changeBackground", trackEvent: false, perform: (elements, appState, value) => { return { @@ -327,6 +334,7 @@ export const actionChangeBackgroundColor = register({ export const actionChangeFillStyle = register({ name: "changeFillStyle", + label: "labels.fill", trackEvent: false, perform: (elements, appState, value, app) => { trackEvent( @@ -404,6 +412,7 @@ export const actionChangeFillStyle = register({ export const actionChangeStrokeWidth = register({ name: "changeStrokeWidth", + label: "labels.strokeWidth", trackEvent: false, perform: (elements, appState, value) => { return { @@ -457,6 +466,7 @@ export const actionChangeStrokeWidth = register({ export const actionChangeSloppiness = register({ name: "changeSloppiness", + label: "labels.sloppiness", trackEvent: false, perform: (elements, appState, value) => { return { @@ -508,6 +518,7 @@ export const actionChangeSloppiness = register({ export const actionChangeStrokeStyle = register({ name: "changeStrokeStyle", + label: "labels.strokeStyle", trackEvent: false, perform: (elements, appState, value) => { return { @@ -558,6 +569,7 @@ export const actionChangeStrokeStyle = register({ export const actionChangeOpacity = register({ name: "changeOpacity", + label: "labels.opacity", trackEvent: false, perform: (elements, appState, value) => { return { @@ -599,11 +611,12 @@ export const actionChangeOpacity = register({ export const actionChangeFontSize = register({ name: "changeFontSize", + label: "labels.fontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, () => value, value); + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, () => value, value); }, - PanelComponent: ({ elements, appState, updateData }) => ( + PanelComponent: ({ elements, appState, updateData, app }) => (
{t("labels.fontSize")} - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, (hasSelection) => hasSelection ? null @@ -662,9 +682,11 @@ export const actionChangeFontSize = register({ export const actionDecreaseFontSize = register({ name: "decreaseFontSize", + label: "labels.decreaseFontSize", + icon: fontSizeIcon, trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, (element) => + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => Math.round( // get previous value before relative increase (doesn't work fully // due to rounding and float precision issues) @@ -684,9 +706,11 @@ export const actionDecreaseFontSize = register({ export const actionIncreaseFontSize = register({ name: "increaseFontSize", + label: "labels.increaseFontSize", + icon: fontSizeIcon, trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, (element) => + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), ); }, @@ -702,8 +726,9 @@ export const actionIncreaseFontSize = register({ export const actionChangeFontFamily = register({ name: "changeFontFamily", + label: "labels.fontFamily", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -717,7 +742,11 @@ export const actionChangeFontFamily = register({ lineHeight: getDefaultLineHeight(value), }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); return newElement; } @@ -732,7 +761,7 @@ export const actionChangeFontFamily = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, app }) => { const options: { value: FontFamilyValues; text: string; @@ -772,14 +801,21 @@ export const actionChangeFontFamily = register({ if (isTextElement(element)) { return element.fontFamily; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); if (boundTextElement) { return boundTextElement.fontFamily; } return null; }, (element) => - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, (hasSelection) => hasSelection ? null @@ -794,8 +830,9 @@ export const actionChangeFontFamily = register({ export const actionChangeTextAlign = register({ name: "changeTextAlign", + label: "Change text alignment", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -806,7 +843,11 @@ export const actionChangeTextAlign = register({ oldElement, { textAlign: value }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); return newElement; } @@ -821,7 +862,8 @@ export const actionChangeTextAlign = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, app }) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); return (
{t("labels.textAlign")} @@ -854,14 +896,18 @@ export const actionChangeTextAlign = register({ if (isTextElement(element)) { return element.textAlign; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + elementsMap, + ); if (boundTextElement) { return boundTextElement.textAlign; } return null; }, (element) => - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement(element, elementsMap) !== null, (hasSelection) => hasSelection ? null : appState.currentItemTextAlign, )} @@ -874,8 +920,9 @@ export const actionChangeTextAlign = register({ export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", + label: "Change vertical alignment", trackEvent: { category: "element" }, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -887,7 +934,11 @@ export const actionChangeVerticalAlign = register({ { verticalAlign: value }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), + ); return newElement; } @@ -901,7 +952,7 @@ export const actionChangeVerticalAlign = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, app }) => { return (
@@ -933,14 +984,21 @@ export const actionChangeVerticalAlign = register({ if (isTextElement(element) && element.containerId) { return element.verticalAlign; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); if (boundTextElement) { return boundTextElement.verticalAlign; } return null; }, (element) => - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), )} onChange={(value) => updateData(value)} @@ -952,6 +1010,7 @@ export const actionChangeVerticalAlign = register({ export const actionChangeRoundness = register({ name: "changeRoundness", + label: "Change edge roundness", trackEvent: false, perform: (elements, appState, value) => { return { @@ -1090,6 +1149,7 @@ const getArrowheadOptions = (flip: boolean) => { export const actionChangeArrowhead = register({ name: "changeArrowhead", + label: "Change arrowheads", trackEvent: false, perform: ( elements, diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 49f5072ce..2d682166f 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -6,10 +6,14 @@ import { ExcalidrawElement } from "../element/types"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; import { excludeElementsInFramesFromSelection } from "../scene/selection"; +import { selectAllIcon } from "../components/icons"; export const actionSelectAll = register({ name: "selectAll", + label: "labels.selectAll", + icon: selectAllIcon, trackEvent: { category: "canvas" }, + viewMode: false, perform: (elements, appState, value, app) => { if (appState.editingLinearElement) { return false; @@ -43,12 +47,11 @@ export const actionSelectAll = register({ // single linear element selected Object.keys(selectedElementIds).length === 1 && isLinearElement(elements[0]) - ? new LinearElementEditor(elements[0], app.scene) + ? new LinearElementEditor(elements[0]) : null, }, commitToHistory: true, }; }, - contextItemLabel: "labels.selectAll", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, }); diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 9c6589bbc..8c0bc5370 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -25,19 +25,25 @@ import { } from "../element/typeChecks"; import { getSelectedElements } from "../scene"; import { ExcalidrawTextElement } from "../element/types"; +import { paintIcon } from "../components/icons"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; export const actionCopyStyles = register({ name: "copyStyles", + label: "labels.copyStyles", + icon: paintIcon, trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { const elementsCopied = []; const element = elements.find((el) => appState.selectedElementIds[el.id]); elementsCopied.push(element); if (element && hasBoundTextElement(element)) { - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); elementsCopied.push(boundTextElement); } if (element) { @@ -51,15 +57,16 @@ export const actionCopyStyles = register({ commitToHistory: false, }; }, - contextItemLabel: "labels.copyStyles", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C, }); export const actionPasteStyles = register({ name: "pasteStyles", + label: "labels.pasteStyles", + icon: paintIcon, trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { const elementsCopied = JSON.parse(copiedStyles); const pastedElement = elementsCopied[0]; const boundTextElement = elementsCopied[1]; @@ -125,7 +132,11 @@ export const actionPasteStyles = register({ element.id === newElement.containerId, ) || null; } - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } if ( @@ -152,7 +163,6 @@ export const actionPasteStyles = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.pasteStyles", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, }); diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index e4f930bff..412da0119 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -5,6 +5,7 @@ import { AppState } from "../types"; export const actionToggleGridMode = register({ name: "gridMode", + label: "labels.showGrid", viewMode: true, trackEvent: { category: "canvas", @@ -24,6 +25,5 @@ export const actionToggleGridMode = register({ predicate: (element, appState, props) => { return typeof props.gridModeEnabled === "undefined"; }, - contextItemLabel: "labels.showGrid", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, }); diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index 60986137b..2f9a148c0 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,9 +1,12 @@ +import { magnetIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; import { register } from "./register"; export const actionToggleObjectsSnapMode = register({ name: "objectsSnapMode", - viewMode: true, + label: "buttons.objectsSnapMode", + icon: magnetIcon, + viewMode: false, trackEvent: { category: "canvas", predicate: (appState) => !appState.objectsSnapModeEnabled, @@ -22,7 +25,6 @@ export const actionToggleObjectsSnapMode = register({ 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, }); diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 71ba6bef1..74d0e0410 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,8 +1,12 @@ import { register } from "./register"; import { CODES, KEYS } from "../keys"; +import { abacusIcon } from "../components/icons"; export const actionToggleStats = register({ name: "stats", + label: "stats.title", + icon: abacusIcon, + paletteName: "Toggle stats", viewMode: true, trackEvent: { category: "menu" }, perform(elements, appState) { @@ -15,7 +19,6 @@ export const actionToggleStats = register({ }; }, checked: (appState) => appState.showStats, - contextItemLabel: "stats.title", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH, }); diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index dc9db0c37..f3c5e4da6 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,8 +1,12 @@ +import { eyeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; import { register } from "./register"; export const actionToggleViewMode = register({ name: "viewMode", + label: "labels.viewMode", + paletteName: "Toggle view mode", + icon: eyeIcon, viewMode: true, trackEvent: { category: "canvas", @@ -21,7 +25,6 @@ export const actionToggleViewMode = register({ predicate: (elements, appState, appProps) => { return typeof appProps.viewModeEnabled === "undefined"; }, - contextItemLabel: "labels.viewMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, }); diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index 28956640c..fd397582a 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,8 +1,12 @@ +import { coffeeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; import { register } from "./register"; export const actionToggleZenMode = register({ name: "zenMode", + label: "buttons.zenMode", + icon: coffeeIcon, + paletteName: "Toggle zen mode", viewMode: true, trackEvent: { category: "canvas", @@ -21,7 +25,6 @@ export const actionToggleZenMode = register({ predicate: (elements, appState, appProps) => { return typeof appProps.zenModeEnabled === "undefined"; }, - contextItemLabel: "buttons.zenMode", keyTest: (event) => !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, }); diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 17ecde1a6..7b68a00d5 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { moveOneLeft, moveOneRight, @@ -19,6 +18,8 @@ import { isDarwin } from "../constants"; export const actionSendBackward = register({ name: "sendBackward", + label: "labels.sendBackward", + icon: SendBackwardIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { @@ -27,7 +28,6 @@ export const actionSendBackward = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.sendBackward", keyPriority: 40, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && @@ -47,6 +47,8 @@ export const actionSendBackward = register({ export const actionBringForward = register({ name: "bringForward", + label: "labels.bringForward", + icon: BringForwardIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { @@ -55,7 +57,6 @@ export const actionBringForward = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.bringForward", keyPriority: 40, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && @@ -75,6 +76,8 @@ export const actionBringForward = register({ export const actionSendToBack = register({ name: "sendToBack", + label: "labels.sendToBack", + icon: SendToBackIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { return { @@ -83,7 +86,6 @@ export const actionSendToBack = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.sendToBack", keyTest: (event) => isDarwin ? event[KEYS.CTRL_OR_CMD] && @@ -110,6 +112,8 @@ export const actionSendToBack = register({ export const actionBringToFront = register({ name: "bringToFront", + label: "labels.bringToFront", + icon: BringToFrontIcon, trackEvent: { category: "element" }, perform: (elements, appState) => { @@ -119,7 +123,6 @@ export const actionBringToFront = register({ commitToHistory: true, }; }, - contextItemLabel: "labels.bringToFront", keyTest: (event) => isDarwin ? event[KEYS.CTRL_OR_CMD] && diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index b4551acf5..092060425 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; -export { actionLink } from "../element/Hyperlink"; +export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index fc56d1bda..90dfe6088 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -10,6 +10,7 @@ import { import { ExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; +import { isPromiseLike } from "../utils"; const trackAction = ( action: Action, @@ -55,7 +56,7 @@ export class ActionManager { app: AppClassProperties, ) { this.updater = (actionResult) => { - if (actionResult && "then" in actionResult) { + if (isPromiseLike(actionResult)) { actionResult.then((actionResult) => { return updater(actionResult); }); diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index 20ab9f7b4..3f1be97d5 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -36,9 +36,22 @@ export type ShortcutName = | "flipVertical" | "hyperlink" | "toggleElementLock" + | "resetZoom" + | "zoomOut" + | "zoomIn" + | "zoomToFit" + | "zoomToFitSelectionInViewport" + | "zoomToFitSelection" + | "toggleEraserTool" + | "toggleHandTool" + | "setFrameAsActiveTool" + | "saveFileToDisk" + | "saveToActiveFile" + | "toggleShortcuts" > | "saveScene" - | "imageExport"; + | "imageExport" + | "commandPalette"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -46,6 +59,10 @@ const shortcutMap: Record = { loadScene: [getShortcutKey("CtrlOrCmd+O")], clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")], imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")], + commandPalette: [ + getShortcutKey("CtrlOrCmd+/"), + getShortcutKey("CtrlOrCmd+Shift+P"), + ], cut: [getShortcutKey("CtrlOrCmd+X")], copy: [getShortcutKey("CtrlOrCmd+C")], paste: [getShortcutKey("CtrlOrCmd+V")], @@ -83,10 +100,24 @@ const shortcutMap: Record = { viewMode: [getShortcutKey("Alt+R")], hyperlink: [getShortcutKey("CtrlOrCmd+K")], toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")], + resetZoom: [getShortcutKey("CtrlOrCmd+0")], + zoomOut: [getShortcutKey("CtrlOrCmd+-")], + zoomIn: [getShortcutKey("CtrlOrCmd++")], + zoomToFitSelection: [getShortcutKey("Shift+3")], + zoomToFit: [getShortcutKey("Shift+1")], + zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")], + toggleEraserTool: [getShortcutKey("E")], + toggleHandTool: [getShortcutKey("H")], + setFrameAsActiveTool: [getShortcutKey("F")], + saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], + saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], + toggleShortcuts: [getShortcutKey("?")], }; -export const getShortcutFromShortcutName = (name: ShortcutName) => { +export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { const shortcuts = shortcutMap[name]; // if multiple shortcuts available, take the first one - return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; + return shortcuts && shortcuts.length > 0 + ? shortcuts[idx] || shortcuts[0] + : ""; }; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c74e19552..18503363f 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -5,10 +5,16 @@ import { AppState, ExcalidrawProps, BinaryFiles, + UIAppState, } from "../types"; import { MarkOptional } from "../utility-types"; -export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; +export type ActionSource = + | "ui" + | "keyboard" + | "contextMenu" + | "api" + | "commandPalette"; /** if false, the action should be prevented */ export type ActionResult = @@ -124,12 +130,13 @@ export type ActionName = | "setFrameAsActiveTool" | "setEmbeddableAsActiveTool" | "createContainerFromText" - | "wrapTextInContainer"; + | "wrapTextInContainer" + | "commandPalette"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; appState: AppState; - updateData: (formData?: any) => void; + updateData: (formData?: T) => void; appProps: ExcalidrawProps; data?: Record; app: AppClassProperties; @@ -137,6 +144,20 @@ export type PanelComponentProps = { export interface Action { name: ActionName; + label: + | string + | (( + elements: readonly ExcalidrawElement[], + appState: Readonly, + app: AppClassProperties, + ) => string); + keywords?: string[]; + icon?: + | React.ReactNode + | (( + appState: UIAppState, + elements: readonly ExcalidrawElement[], + ) => React.ReactNode); PanelComponent?: React.FC; perform: ActionFn; keyPriority?: number; @@ -146,13 +167,6 @@ export interface Action { elements: readonly ExcalidrawElement[], app: AppClassProperties, ) => boolean; - contextItemLabel?: - | string - | (( - elements: readonly ExcalidrawElement[], - appState: Readonly, - app: AppClassProperties, - ) => string); predicate?: ( elements: readonly ExcalidrawElement[], appState: AppState, diff --git a/packages/excalidraw/align.ts b/packages/excalidraw/align.ts index 06382838f..90ecabb11 100644 --- a/packages/excalidraw/align.ts +++ b/packages/excalidraw/align.ts @@ -1,4 +1,4 @@ -import { ExcalidrawElement } from "./element/types"; +import { ElementsMap, ExcalidrawElement } from "./element/types"; import { newElementWith } from "./element/mutateElement"; import { BoundingBox, getCommonBoundingBox } from "./element/bounds"; import { getMaximumGroups } from "./groups"; @@ -10,10 +10,13 @@ export interface Alignment { export const alignElements = ( selectedElements: ExcalidrawElement[], + elementsMap: ElementsMap, alignment: Alignment, ): ExcalidrawElement[] => { - const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements); - + const groups: ExcalidrawElement[][] = getMaximumGroups( + selectedElements, + elementsMap, + ); const selectionBoundingBox = getCommonBoundingBox(selectedElements); return groups.flatMap((group) => { diff --git a/packages/excalidraw/analytics.ts b/packages/excalidraw/analytics.ts index 671f59202..bd4b6191e 100644 --- a/packages/excalidraw/analytics.ts +++ b/packages/excalidraw/analytics.ts @@ -1,6 +1,6 @@ // place here categories that you want to track. We want to track just a // small subset of categories at a given time. -const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[]; +const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[]; export const trackEvent = ( category: string, diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts new file mode 100644 index 000000000..de5fd08fd --- /dev/null +++ b/packages/excalidraw/animated-trail.ts @@ -0,0 +1,148 @@ +import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer"; +import { AnimationFrameHandler } from "./animation-frame-handler"; +import { AppState } from "./types"; +import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils"; +import type App from "./components/App"; +import { SVG_NS } from "./constants"; + +export interface Trail { + start(container: SVGSVGElement): void; + stop(): void; + + startPath(x: number, y: number): void; + addPointToPath(x: number, y: number): void; + endPath(): void; +} + +export interface AnimatedTrailOptions { + fill: (trail: AnimatedTrail) => string; +} + +export class AnimatedTrail implements Trail { + private currentTrail?: LaserPointer; + private pastTrails: LaserPointer[] = []; + + private container?: SVGSVGElement; + private trailElement: SVGPathElement; + + constructor( + private animationFrameHandler: AnimationFrameHandler, + private app: App, + private options: Partial & + Partial, + ) { + this.animationFrameHandler.register(this, this.onFrame.bind(this)); + + this.trailElement = document.createElementNS(SVG_NS, "path"); + } + + get hasCurrentTrail() { + return !!this.currentTrail; + } + + hasLastPoint(x: number, y: number) { + if (this.currentTrail) { + const len = this.currentTrail.originalPoints.length; + return ( + this.currentTrail.originalPoints[len - 1][0] === x && + this.currentTrail.originalPoints[len - 1][1] === y + ); + } + + return false; + } + + start(container?: SVGSVGElement) { + if (container) { + this.container = container; + } + + if (this.trailElement.parentNode !== this.container && this.container) { + this.container.appendChild(this.trailElement); + } + + this.animationFrameHandler.start(this); + } + + stop() { + this.animationFrameHandler.stop(this); + + if (this.trailElement.parentNode === this.container) { + this.container?.removeChild(this.trailElement); + } + } + + startPath(x: number, y: number) { + this.currentTrail = new LaserPointer(this.options); + + this.currentTrail.addPoint([x, y, performance.now()]); + + this.update(); + } + + addPointToPath(x: number, y: number) { + if (this.currentTrail) { + this.currentTrail.addPoint([x, y, performance.now()]); + this.update(); + } + } + + endPath() { + if (this.currentTrail) { + this.currentTrail.close(); + this.currentTrail.options.keepHead = false; + this.pastTrails.push(this.currentTrail); + this.currentTrail = undefined; + this.update(); + } + } + + private update() { + this.start(); + } + + private onFrame() { + const paths: string[] = []; + + for (const trail of this.pastTrails) { + paths.push(this.drawTrail(trail, this.app.state)); + } + + if (this.currentTrail) { + const currentPath = this.drawTrail(this.currentTrail, this.app.state); + + paths.push(currentPath); + } + + this.pastTrails = this.pastTrails.filter((trail) => { + return trail.getStrokeOutline().length !== 0; + }); + + if (paths.length === 0) { + this.stop(); + } + + const svgPaths = paths.join(" ").trim(); + + this.trailElement.setAttribute("d", svgPaths); + this.trailElement.setAttribute( + "fill", + (this.options.fill ?? (() => "black"))(this), + ); + } + + private drawTrail(trail: LaserPointer, state: AppState): string { + const stroke = trail + .getStrokeOutline(trail.options.size / state.zoom.value) + .map(([x, y]) => { + const result = sceneCoordsToViewportCoords( + { sceneX: x, sceneY: y }, + state, + ); + + return [result.x, result.y]; + }); + + return getSvgPathFromStroke(stroke, true); + } +} diff --git a/packages/excalidraw/animation-frame-handler.ts b/packages/excalidraw/animation-frame-handler.ts new file mode 100644 index 000000000..b1a984466 --- /dev/null +++ b/packages/excalidraw/animation-frame-handler.ts @@ -0,0 +1,79 @@ +export type AnimationCallback = (timestamp: number) => void | boolean; + +export type AnimationTarget = { + callback: AnimationCallback; + stopped: boolean; +}; + +export class AnimationFrameHandler { + private targets = new WeakMap(); + private rafIds = new WeakMap(); + + register(key: object, callback: AnimationCallback) { + this.targets.set(key, { callback, stopped: true }); + } + + start(key: object) { + const target = this.targets.get(key); + + if (!target) { + return; + } + + if (this.rafIds.has(key)) { + return; + } + + this.targets.set(key, { ...target, stopped: false }); + this.scheduleFrame(key); + } + + stop(key: object) { + const target = this.targets.get(key); + if (target && !target.stopped) { + this.targets.set(key, { ...target, stopped: true }); + } + + this.cancelFrame(key); + } + + private constructFrame(key: object): FrameRequestCallback { + return (timestamp: number) => { + const target = this.targets.get(key); + + if (!target) { + return; + } + + const shouldAbort = this.onFrame(target, timestamp); + + if (!target.stopped && !shouldAbort) { + this.scheduleFrame(key); + } else { + this.cancelFrame(key); + } + }; + } + + private scheduleFrame(key: object) { + const rafId = requestAnimationFrame(this.constructFrame(key)); + + this.rafIds.set(key, rafId); + } + + private cancelFrame(key: object) { + if (this.rafIds.has(key)) { + const rafId = this.rafIds.get(key)!; + + cancelAnimationFrame(rafId); + } + + this.rafIds.delete(key); + } + + private onFrame(target: AnimationTarget, timestamp: number): boolean { + const shouldAbort = target.callback(timestamp); + + return shouldAbort ?? false; + } +} diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 0089f57e9..a0ab233c9 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -7,9 +7,7 @@ import { EXPORT_SCALES, THEME, } from "./constants"; -import { t } from "./i18n"; import { AppState, NormalizedZoomValue } from "./types"; -import { getDateTime } from "./utils"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) ? devicePixelRatio @@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit< isRotating: false, lastPointerDownWith: "mouse", multiElement: null, - name: `${t("labels.untitled")}-${getDateTime()}`, + name: null, contextMenu: null, openMenu: null, openPopup: null, @@ -105,6 +103,8 @@ export const getDefaultAppState = (): Omit< y: 0, }, objectsSnapModeEnabled: false, + userToFollow: null, + followedBy: new Set(), }; }; @@ -215,6 +215,8 @@ const APP_STATE_STORAGE_CONF = (< snapLines: { browser: false, export: false, server: false }, originSnapOffset: { browser: false, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false }, + userToFollow: { browser: false, export: false, server: false }, + followedBy: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/assets/lock.svg b/packages/excalidraw/assets/lock.svg deleted file mode 100644 index aa9dbf170..000000000 --- a/packages/excalidraw/assets/lock.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts index 354098918..439080bd5 100644 --- a/packages/excalidraw/clients.ts +++ b/packages/excalidraw/clients.ts @@ -1,3 +1,18 @@ +import { + COLOR_CHARCOAL_BLACK, + COLOR_VOICE_CALL, + COLOR_WHITE, + THEME, +} from "./constants"; +import { roundRect } from "./renderer/roundRect"; +import { InteractiveCanvasRenderConfig } from "./scene/types"; +import { + Collaborator, + InteractiveCanvasAppState, + SocketId, + UserIdleState, +} from "./types"; + function hashToInteger(id: string) { let hash = 0; if (id.length === 0) { @@ -11,14 +26,12 @@ function hashToInteger(id: string) { } export const getClientColor = ( - /** - * any uniquely identifying key, such as user id or socket id - */ - id: string, + socketId: SocketId, + collaborator: Collaborator | undefined, ) => { // to get more even distribution in case `id` is not uniformly distributed to // begin with, we hash it - const hash = Math.abs(hashToInteger(id)); + const hash = Math.abs(hashToInteger(collaborator?.id || socketId)); // we want to get a multiple of 10 number in the range of 0-360 (in other // words a hue value of step size 10). There are 37 such values including 0. const hue = (hash % 37) * 10; @@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => { firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" ).toUpperCase(); }; + +export const renderRemoteCursors = ({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, +}: { + context: CanvasRenderingContext2D; + renderConfig: InteractiveCanvasRenderConfig; + appState: InteractiveCanvasAppState; + normalizedWidth: number; + normalizedHeight: number; +}) => { + // Paint remote pointers + for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) { + let { x, y } = pointer; + + const collaborator = appState.collaborators.get(socketId); + + x -= appState.offsetLeft; + y -= appState.offsetTop; + + const width = 11; + const height = 14; + + const isOutOfBounds = + x < 0 || + x > normalizedWidth - width || + y < 0 || + y > normalizedHeight - height; + + x = Math.max(x, 0); + x = Math.min(x, normalizedWidth - width); + y = Math.max(y, 0); + y = Math.min(y, normalizedHeight - height); + + const background = getClientColor(socketId, collaborator); + + context.save(); + context.strokeStyle = background; + context.fillStyle = background; + + const userState = renderConfig.remotePointerUserStates.get(socketId); + const isInactive = + isOutOfBounds || + userState === UserIdleState.IDLE || + userState === UserIdleState.AWAY; + + if (isInactive) { + context.globalAlpha = 0.3; + } + + if (renderConfig.remotePointerButton.get(socketId) === "down") { + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 3; + context.strokeStyle = "#ffffff88"; + context.stroke(); + context.closePath(); + + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 1; + context.strokeStyle = background; + context.stroke(); + context.closePath(); + } + + // TODO remove the dark theme color after we stop inverting canvas colors + const IS_SPEAKING_COLOR = + appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL; + + const isSpeaking = collaborator?.isSpeaking; + + if (isSpeaking) { + // cursor outline for currently speaking user + context.fillStyle = IS_SPEAKING_COLOR; + context.strokeStyle = IS_SPEAKING_COLOR; + context.lineWidth = 10; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + } + + // Background (white outline) for arrow + context.fillStyle = COLOR_WHITE; + context.strokeStyle = COLOR_WHITE; + context.lineWidth = 6; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + + // Arrow + context.fillStyle = background; + context.strokeStyle = background; + context.lineWidth = 2; + context.lineJoin = "round"; + context.beginPath(); + if (isInactive) { + context.moveTo(x - 1, y - 1); + context.lineTo(x - 1, y + 15); + context.lineTo(x + 5, y + 10); + context.lineTo(x + 12, y + 9); + context.closePath(); + context.fill(); + } else { + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.fill(); + context.stroke(); + } + + const username = renderConfig.remotePointerUsernames.get(socketId) || ""; + + if (!isOutOfBounds && username) { + context.font = "600 12px sans-serif"; // font has to be set before context.measureText() + + const offsetX = (isSpeaking ? x + 0 : x) + width / 2; + const offsetY = (isSpeaking ? y + 0 : y) + height + 2; + const paddingHorizontal = 5; + const paddingVertical = 3; + const measure = context.measureText(username); + const measureHeight = + measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; + const finalHeight = Math.max(measureHeight, 12); + + const boxX = offsetX - 1; + const boxY = offsetY - 1; + const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; + const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; + if (context.roundRect) { + context.beginPath(); + context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); + context.fillStyle = background; + context.fill(); + context.strokeStyle = COLOR_WHITE; + context.stroke(); + + if (isSpeaking) { + context.beginPath(); + context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8); + context.strokeStyle = IS_SPEAKING_COLOR; + context.stroke(); + } + } else { + roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE); + } + context.fillStyle = COLOR_CHARCOAL_BLACK; + + context.fillText( + username, + offsetX + paddingHorizontal + 1, + offsetY + + paddingVertical + + measure.actualBoundingBoxAscent + + Math.floor((finalHeight - measureHeight) / 2) + + 2, + ); + + // draw three vertical bars signalling someone is speaking + if (isSpeaking) { + context.fillStyle = IS_SPEAKING_COLOR; + const barheight = 8; + const margin = 8; + const gap = 5; + context.fillRect( + boxX + boxWidth + margin, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + context.fillRect( + boxX + boxWidth + margin + gap, + boxY + (boxHeight / 2 - (barheight * 2) / 2), + 2, + barheight * 2, + ); + context.fillRect( + boxX + boxWidth + margin + gap * 2, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + } + } + + context.restore(); + context.closePath(); + } +}; diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index a88402d69..e24961c64 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -16,8 +16,7 @@ import { import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; -import { isMemberOf, isPromiseLike } from "./utils"; -import { t } from "./i18n"; +import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -126,6 +125,7 @@ export const serializeAsClipboardJSON = ({ elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | null; }) => { + const elementsMap = arrayToMap(elements); const framesToCopy = new Set( elements.filter((element) => isFrameLikeElement(element)), ); @@ -152,8 +152,8 @@ export const serializeAsClipboardJSON = ({ type: EXPORT_DATA_TYPES.excalidrawClipboard, elements: elements.map((element) => { if ( - getContainingFrame(element) && - !framesToCopy.has(getContainingFrame(element)!) + getContainingFrame(element, elementsMap) && + !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { const copiedElement = deepCopyElement(element); mutateElement(copiedElement, { @@ -434,7 +434,7 @@ export const copyTextToSystemClipboard = async ( // (3) if that fails, use document.execCommand if (!copyTextViaExecCommand(text)) { - throw new Error(t("errors.copyToSystemClipboardFailed")); + throw new Error("Error copying to clipboard."); } }; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index f07664f1a..dd224e104 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,7 +1,11 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { ActionManager } from "../actions/manager"; -import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement, ExcalidrawElementType } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawElementType, + NonDeletedElementsMap, + NonDeletedSceneElementsMap, +} from "../element/types"; import { t } from "../i18n"; import { useDevice } from "./App"; import { @@ -42,19 +46,50 @@ import { import { KEYS } from "../keys"; import { useTunnels } from "../context/tunnels"; +export const canChangeStrokeColor = ( + appState: UIAppState, + targetElements: ExcalidrawElement[], +) => { + let commonSelectedType: ExcalidrawElementType | null = + targetElements[0]?.type || null; + + for (const element of targetElements) { + if (element.type !== commonSelectedType) { + commonSelectedType = null; + break; + } + } + + return ( + (hasStrokeColor(appState.activeTool.type) && + appState.activeTool.type !== "image" && + commonSelectedType !== "image" && + commonSelectedType !== "frame" && + commonSelectedType !== "magicframe") || + targetElements.some((element) => hasStrokeColor(element.type)) + ); +}; + +export const canChangeBackgroundColor = ( + appState: UIAppState, + targetElements: ExcalidrawElement[], +) => { + return ( + hasBackground(appState.activeTool.type) || + targetElements.some((element) => hasBackground(element.type)) + ); +}; + export const SelectedShapeActions = ({ appState, - elements, + elementsMap, renderAction, }: { appState: UIAppState; - elements: readonly ExcalidrawElement[]; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; renderAction: ActionManager["renderAction"]; }) => { - const targetElements = getTargetElements( - getNonDeletedElements(elements), - appState, - ); + const targetElements = getTargetElements(elementsMap, appState); let isSingleElementBoundContainer = false; if ( @@ -75,35 +110,17 @@ export const SelectedShapeActions = ({ (element) => hasBackground(element.type) && !isTransparent(element.backgroundColor), ); - const showChangeBackgroundIcons = - hasBackground(appState.activeTool.type) || - targetElements.some((element) => hasBackground(element.type)); const showLinkIcon = targetElements.length === 1 || isSingleElementBoundContainer; - let commonSelectedType: ExcalidrawElementType | null = - targetElements[0]?.type || null; - - for (const element of targetElements) { - if (element.type !== commonSelectedType) { - commonSelectedType = null; - break; - } - } - return (
- {((hasStrokeColor(appState.activeTool.type) && - appState.activeTool.type !== "image" && - commonSelectedType !== "image" && - commonSelectedType !== "frame" && - commonSelectedType !== "magicframe") || - targetElements.some((element) => hasStrokeColor(element.type))) && + {canChangeStrokeColor(appState, targetElements) && renderAction("changeStrokeColor")}
- {showChangeBackgroundIcons && ( + {canChangeBackgroundColor(appState, targetElements) && (
{renderAction("changeBackgroundColor")}
)} {showFillIcons && renderAction("changeFillStyle")} @@ -137,12 +154,12 @@ export const SelectedShapeActions = ({ {renderAction("changeFontFamily")} {(appState.activeTool.type === "text" || - suppportsHorizontalAlign(targetElements)) && + suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} )} - {shouldAllowVerticalAlign(targetElements) && + {shouldAllowVerticalAlign(targetElements, elementsMap) && renderAction("changeVerticalAlign")} {(canHaveArrowheads(appState.activeTool.type) || targetElements.some((element) => canHaveArrowheads(element.type))) && ( @@ -306,6 +323,25 @@ export const ShapesSwitcher = ({ title={t("toolBar.extraTools")} > {extraToolsIcon} + {app.props.aiEnabled !== false && ( +
+ AI +
+ )} setIsExtraToolsMenuOpen(false)} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 69a60249c..6a0fd1031 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5,7 +5,6 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import rough from "roughjs/bin/rough"; import clsx from "clsx"; import { nanoid } from "nanoid"; - import { actionAddToLibrary, actionBringForward, @@ -58,7 +57,6 @@ import { DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, - ELEMENT_READY_TO_ERASE_OPACITY, ELEMENT_SHIFT_TRANSLATE_AMOUNT, ELEMENT_TRANSLATE_AMOUNT, ENV, @@ -68,7 +66,6 @@ import { GRID_SIZE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, - isAndroid, isBrave, LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, @@ -91,6 +88,8 @@ import { POINTER_EVENTS, TOOL_TYPE, EDITOR_LS_KEYS, + isIOS, + supportsResizeObserver, } from "../constants"; import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; @@ -108,8 +107,6 @@ import { getResizeOffsetXY, getLockedLinearCursorAlignSize, getTransformHandleTypeFromCoords, - hitTest, - isHittingElementBoundingBoxWithoutHittingElement, isInvisiblySmallElement, isNonDeletedElement, isTextElement, @@ -117,10 +114,10 @@ import { newLinearElement, newTextElement, newImageElement, - textWysiwyg, transformElements, updateTextElement, redrawTextBoundingBox, + getElementAbsoluteCoords, } from "../element"; import { bindOrUnbindLinearElement, @@ -164,6 +161,7 @@ import { isIframeElement, isIframeLikeElement, isMagicFrameElement, + isTextBindableContainer, } from "../element/typeChecks"; import { ExcalidrawBindableElement, @@ -183,6 +181,8 @@ import { ExcalidrawIframeLikeElement, IframeData, ExcalidrawIframeElement, + ExcalidrawEmbeddableElement, + Ordered, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -213,18 +213,25 @@ import { } from "../math"; import { calculateScrollCenter, - getElementsAtPosition, getElementsWithinSelection, getNormalizedZoom, getSelectedElements, hasBackground, - isOverScrollBars, isSomeElementSelected, } from "../scene"; import Scene from "../scene/Scene"; import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; import { findShapeByKey } from "../shapes"; +import { + GeometricShape, + getClosedCurveShape, + getCurveShape, + getEllipseShape, + getFreedrawShape, + getPolygonShape, +} from "../../utils/geometry/shape"; +import { isPointInShape } from "../../utils/collision"; import { AppClassProperties, AppProps, @@ -245,6 +252,10 @@ import { KeyboardModifiersObject, CollaboratorPointer, ToolType, + OnUserFollowedPayload, + UnsubscribeCallback, + EmbedsValidationStatus, + ElementsPendingErasure, } from "../types"; import { debounce, @@ -257,9 +268,7 @@ import { sceneCoordsToViewportCoords, tupleToCoors, viewportCoordsToSceneCoords, - withBatchedUpdates, wrapEvent, - withBatchedUpdatesThrottled, updateObject, updateActiveTool, getShortcutKey, @@ -268,11 +277,16 @@ import { muteFSAbortError, isTestEnv, easeOut, + arrayToMap, + updateStable, + addEventListener, + normalizeEOL, + getDateTime, } from "../utils"; import { createSrcDoc, embeddableURLValidator, - extractSrc, + maybeParseEmbedSrc, getEmbedLink, } from "../element/embeddable"; import { @@ -314,18 +328,14 @@ import { getContainerElement, getDefaultLineHeight, getLineHeightInPx, - getTextBindableContainerAtPosition, isMeasureTextSupported, isValidTextContainer, } from "../element/textElement"; -import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, - isPointHittingLink, - isPointHittingLinkIcon, -} from "../element/Hyperlink"; +} from "../components/hyperlink/Hyperlink"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; @@ -344,6 +354,8 @@ import { updateFrameMembershipOfSelectedElements, isElementInFrame, getFrameLikeTitle, + getElementsOverlappingFrame, + filterElementsEligibleAsFrameChildren, } from "../frame"; import { excludeElementsInFramesFromSelection, @@ -381,8 +393,7 @@ 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"; +import { SVGLayer } from "./SVGLayer"; import { setEraserCursor, setCursor, @@ -392,11 +403,32 @@ import { import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; import { MagicCacheData, diagramToHTML } from "../data/magic"; -import { elementsOverlappingBBox, exportToBlob } from "../../utils"; +import { exportToBlob } from "../../utils/export"; import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; +import FollowMode from "./FollowMode/FollowMode"; +import { AnimationFrameHandler } from "../animation-frame-handler"; +import { AnimatedTrail } from "../animated-trail"; +import { LaserTrails } from "../laser-trails"; +import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +import { getRenderOpacity } from "../renderer/renderElement"; +import { + hitElementBoundText, + hitElementBoundingBox, + hitElementBoundingBoxOnly, + hitElementItself, + shouldTestInside, +} from "../element/collision"; +import { textWysiwyg } from "../element/textWysiwyg"; +import { isOverScrollBars } from "../scene/scrollbars"; +import { syncInvalidIndices, syncMovedIndices } from "../fractionalIndex"; +import { + isPointHittingLink, + isPointHittingLinkIcon, +} from "./hyperlink/helpers"; +import { getShortcutFromShortcutName } from "../actions/shortcuts"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -438,7 +470,7 @@ ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext"; const ExcalidrawSetAppStateContext = React.createContext< React.Component["setState"] >(() => { - console.warn("unitialized ExcalidrawSetAppStateContext context!"); + console.warn("Uninitialized ExcalidrawSetAppStateContext context!"); }); ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext"; @@ -461,9 +493,6 @@ export const useExcalidrawSetAppState = () => export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); -const supportsResizeObserver = - typeof window !== "undefined" && "ResizeObserver" in window; - let didTapTwice: boolean = false; let tappedTwiceTimer = 0; let isHoldingSpace: boolean = false; @@ -485,7 +514,7 @@ let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; -let lastPointerUp: ((event: any) => void) | null = null; +let lastPointerUp: (() => void) | null = null; const gesture: Gesture = { pointers: new Map(), lastCenter: null, @@ -520,14 +549,49 @@ class App extends React.Component { public files: BinaryFiles = {}; public imageCache: AppClassProperties["imageCache"] = new Map(); private iFrameRefs = new Map(); + /** + * Indicates whether the embeddable's url has been validated for rendering. + * If value not set, indicates that the validation is pending. + * Initially or on url change the flag is not reset so that we can guarantee + * the validation came from a trusted source (the editor). + **/ + private embedsValidationStatus: EmbedsValidationStatus = new Map(); + /** embeds that have been inserted to DOM (as a perf optim, we don't want to + * insert to DOM before user initially scrolls to them) */ + private initializedEmbeds = new Set(); + + private elementsPendingErasure: ElementsPendingErasure = new Set(); hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; + lastPointerMoveEvent: PointerEvent | null = null; lastViewportPosition = { x: 0, y: 0 }; - laserPathManager: LaserPathManager = new LaserPathManager(this); + animationFrameHandler = new AnimationFrameHandler(); + + laserTrails = new LaserTrails(this.animationFrameHandler, this); + eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, { + streamline: 0.2, + size: 5, + keepHead: true, + sizeMapping: (c) => { + const DECAY_TIME = 200; + const DECAY_LENGTH = 10; + const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME); + const l = + (DECAY_LENGTH - + Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / + DECAY_LENGTH; + + return Math.min(easeOut(l), easeOut(t)); + }, + fill: () => + this.state.theme === THEME.LIGHT + ? "rgba(0, 0, 0, 0.2)" + : "rgba(255, 255, 255, 0.2)", + }); onChangeEmitter = new Emitter< [ @@ -552,6 +616,15 @@ class App extends React.Component { event: PointerEvent, ] >(); + onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>(); + onScrollChangeEmitter = new Emitter< + [scrollX: number, scrollY: number, zoom: AppState["zoom"]] + >(); + + missingPointerEventCleanupEmitter = new Emitter< + [event: PointerEvent | null] + >(); + onRemoveEventListenersEmitter = new Emitter<[]>(); constructor(props: AppProps) { super(props); @@ -563,7 +636,7 @@ class App extends React.Component { gridModeEnabled = false, objectsSnapModeEnabled = false, theme = defaultAppState.theme, - name = defaultAppState.name, + name = `${t("labels.untitled")}-${getDateTime()}`, } = props; this.state = { ...defaultAppState, @@ -580,7 +653,6 @@ class App extends React.Component { }; this.id = nanoid(); - this.library = new Library(this); this.actionManager = new ActionManager( this.syncActionResult, @@ -607,6 +679,7 @@ class App extends React.Component { getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, + getName: this.getName, registerAction: (action: Action) => { this.actionManager.registerAction(action); }, @@ -621,6 +694,8 @@ class App extends React.Component { onChange: (cb) => this.onChangeEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), + onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), + onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), } as const; if (typeof excalidrawAPI === "function") { excalidrawAPI(api); @@ -824,6 +899,14 @@ class App extends React.Component { ); } + private updateEmbedValidationStatus = ( + element: ExcalidrawEmbeddableElement, + status: boolean, + ) => { + this.embedsValidationStatus.set(element.id, status); + ShapeCache.delete(element); + }; + private updateEmbeddables = () => { const iframeLikes = new Set(); @@ -831,7 +914,7 @@ class App extends React.Component { this.scene.getNonDeletedElements().filter((element) => { if (isEmbeddableElement(element)) { iframeLikes.add(element.id); - if (element.validated == null) { + if (!this.embedsValidationStatus.has(element.id)) { updated = true; const validated = embeddableURLValidator( @@ -839,8 +922,7 @@ class App extends React.Component { this.props.validateEmbeddable, ); - mutateElement(element, { validated }, false); - ShapeCache.delete(element); + this.updateEmbedValidationStatus(element, validated); } } else if (isIframeElement(element)) { iframeLikes.add(element.id); @@ -868,8 +950,10 @@ class App extends React.Component { const embeddableElements = this.scene .getNonDeletedElements() .filter( - (el): el is NonDeleted => - (isEmbeddableElement(el) && !!el.validated) || isIframeElement(el), + (el): el is Ordered> => + (isEmbeddableElement(el) && + this.embedsValidationStatus.get(el.id) === true) || + isIframeElement(el), ); return ( @@ -880,6 +964,24 @@ class App extends React.Component { this.state, ); + const isVisible = isElementInViewport( + el, + normalizedWidth, + normalizedHeight, + this.state, + this.scene.getNonDeletedElementsMap(), + ); + const hasBeenInitialized = this.initializedEmbeds.has(el.id); + + if (isVisible && !hasBeenInitialized) { + this.initializedEmbeds.add(el.id); + } + const shouldRender = isVisible || hasBeenInitialized; + + if (!shouldRender) { + return null; + } + let src: IframeData | null; if (isIframeElement(el)) { @@ -912,7 +1014,7 @@ class App extends React.Component { width: 100%; height: 100%; color: ${ - this.state.theme === "dark" ? "white" : "black" + this.state.theme === THEME.DARK ? "white" : "black" }; } body { @@ -1021,14 +1123,6 @@ class App extends React.Component { src = getEmbedLink(toValidURL(el.link || "")); } - // console.log({ src }); - - const isVisible = isElementInViewport( - el, - normalizedWidth, - normalizedHeight, - this.state, - ); const isActive = this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "active"; @@ -1049,7 +1143,11 @@ class App extends React.Component { }px) scale(${scale})` : "none", display: isVisible ? "block" : "none", - opacity: el.opacity / 100, + opacity: getRenderOpacity( + el, + getContainingFrame(el, this.scene.getNonDeletedElementsMap()), + this.elementsPendingErasure, + ), ["--embeddable-radius" as string]: `${getCornerRadius( Math.min(el.width, el.height), el, @@ -1114,7 +1212,9 @@ class App extends React.Component { title="Excalidraw Embedded Content" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen={true} - sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads" + sandbox={`${ + src?.sandbox?.allowSameOrigin ? "allow-same-origin" : "" + } allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`} /> )}
@@ -1183,7 +1283,7 @@ class App extends React.Component { return null; } - const isDarkTheme = this.state.theme === "dark"; + const isDarkTheme = this.state.theme === THEME.DARK; let frameIndex = 0; let magicFrameIndex = 0; @@ -1206,6 +1306,7 @@ class App extends React.Component { scrollY: this.state.scrollY, zoom: this.state.zoom, }, + this.scene.getNonDeletedElementsMap(), ) ) { // if frame not visible, don't render its name @@ -1220,10 +1321,7 @@ class App extends React.Component { const FRAME_NAME_EDIT_PADDING = 6; const reset = () => { - if (f.name?.trim() === "") { - mutateElement(f, { name: null }); - } - + mutateElement(f, { name: f.name?.trim() || null }); this.setState({ editingFrame: null }); }; @@ -1246,6 +1344,7 @@ class App extends React.Component { name: e.target.value, }); }} + onFocus={(e) => e.target.select()} onBlur={() => reset()} onKeyDown={(event) => { // for some inexplicable reason, `onBlur` triggered on ESC @@ -1333,12 +1432,19 @@ class App extends React.Component { }); }; + private toggleOverscrollBehavior(event: React.PointerEvent) { + // when pointer inside editor, disable overscroll behavior to prevent + // panning to trigger history back/forward on MacOS Chrome + document.documentElement.style.overscrollBehaviorX = + event.type === "pointerenter" ? "none" : "auto"; + } + public render() { const selectedElements = this.scene.getSelectedElements(this.state); const { renderTopRightUI, renderCustomStats } = this.props; const versionNonce = this.scene.getVersionNonce(); - const { canvasElements, visibleElements } = + const { elementsMap, visibleElements } = this.renderer.getRenderableElements({ versionNonce, zoom: this.state.zoom, @@ -1352,6 +1458,8 @@ class App extends React.Component { pendingImageElementId: this.state.pendingImageElementId, }); + const allElementsMap = this.scene.getNonDeletedElementsMap(); + const shouldBlockPointerEvents = !( this.state.editingElement && isLinearElement(this.state.editingElement) @@ -1384,6 +1492,8 @@ class App extends React.Component { onKeyDown={ this.props.handleKeyboardGlobally ? undefined : this.onKeyDown } + onPointerEnter={this.toggleOverscrollBehavior} + onPointerLeave={this.toggleOverscrollBehavior} > @@ -1438,15 +1548,21 @@ class App extends React.Component {
- + {selectedElements.length === 1 && this.state.showHyperlinkPopup && ( )} {this.props.aiEnabled !== false && @@ -1454,6 +1570,7 @@ class App extends React.Component { isMagicFrameElement(firstSelectedElement) && ( { ?.status === "done" && ( { { renderGrid: true, canvasBackgroundColor: this.state.viewBackgroundColor, + embedsValidationStatus: this.embedsValidationStatus, + elementsPendingErasure: this.elementsPendingErasure, }} /> { onPointerDown={this.handleCanvasPointerDown} onDoubleClick={this.handleCanvasDoubleClick} /> + {this.state.userToFollow && ( + + )} {this.renderFrameNames()} {this.renderEmbeddables()} @@ -1630,7 +1759,7 @@ class App extends React.Component { this.files, { exportBackground: this.state.exportBackground, - name: this.state.name, + name: this.getName(), viewBackgroundColor: this.state.viewBackgroundColor, exportingFrame: opts.exportingFrame, }, @@ -1711,11 +1840,10 @@ class App extends React.Component { return; } - const magicFrameChildren = elementsOverlappingBBox({ - elements: this.scene.getNonDeletedElements(), - bounds: magicFrame, - type: "overlap", - }).filter((el) => !isMagicFrameElement(el)); + const magicFrameChildren = getElementsOverlappingFrame( + this.scene.getNonDeletedElements(), + magicFrame, + ).filter((el) => !isMagicFrameElement(el)); if (!magicFrameChildren.length) { if (source === "button") { @@ -1932,7 +2060,7 @@ class App extends React.Component { locked: false, }); - this.scene.addNewElement(frame); + this.scene.insertElement(frame); for (const child of selectedElements) { mutateElement(child, { frameId: frame.id }); @@ -2030,7 +2158,7 @@ class App extends React.Component { let gridSize = actionResult?.appState?.gridSize || null; const theme = actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; - let name = actionResult?.appState?.name ?? this.state.name; + const name = actionResult?.appState?.name ?? this.state.name; const errorMessage = actionResult?.appState?.errorMessage ?? this.state.errorMessage; if (typeof this.props.viewModeEnabled !== "undefined") { @@ -2045,10 +2173,6 @@ class App extends React.Component { gridSize = this.props.gridModeEnabled ? GRID_SIZE : null; } - if (typeof this.props.name !== "undefined") { - name = this.props.name; - } - editingElement = editingElement || actionResult.appState?.editingElement || null; @@ -2352,14 +2476,16 @@ class App extends React.Component { this.removeEventListeners(); this.scene.destroy(); this.library.destroy(); - this.laserPathManager.destroy(); - this.onChangeEmitter.destroy(); + this.laserTrails.stop(); + this.eraserTrail.stop(); + this.onChangeEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); isSomeElementSelected.clearCache(); selectGroupsForSelectedElements.clearCache(); touchTimeout = 0; + document.documentElement.style.overscrollBehaviorX = ""; } private onResize = withBatchedUpdates(() => { @@ -2374,63 +2500,6 @@ class App extends React.Component { this.setState({}); }); - private removeEventListeners() { - document.removeEventListener(EVENT.POINTER_UP, this.removePointer); - document.removeEventListener(EVENT.COPY, this.onCopy); - document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard); - document.removeEventListener(EVENT.CUT, this.onCut); - this.excalidrawContainerRef.current?.removeEventListener( - EVENT.WHEEL, - this.onWheel, - ); - this.nearestScrollableContainer?.removeEventListener( - EVENT.SCROLL, - this.onScroll, - ); - document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false); - document.removeEventListener( - EVENT.MOUSE_MOVE, - this.updateCurrentCursorPosition, - false, - ); - document.removeEventListener(EVENT.KEYUP, this.onKeyUp); - window.removeEventListener(EVENT.RESIZE, this.onResize, false); - window.removeEventListener(EVENT.UNLOAD, this.onUnload, false); - window.removeEventListener(EVENT.BLUR, this.onBlur, false); - this.excalidrawContainerRef.current?.removeEventListener( - EVENT.DRAG_OVER, - this.disableEvent, - false, - ); - this.excalidrawContainerRef.current?.removeEventListener( - EVENT.DROP, - this.disableEvent, - false, - ); - - document.removeEventListener( - EVENT.GESTURE_START, - this.onGestureStart as any, - false, - ); - document.removeEventListener( - EVENT.GESTURE_CHANGE, - this.onGestureChange as any, - false, - ); - document.removeEventListener( - EVENT.GESTURE_END, - this.onGestureEnd as any, - false, - ); - document.removeEventListener( - EVENT.FULLSCREENCHANGE, - this.onFullscreenChange, - ); - - window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false); - } - /** generally invoked only if fullscreen was invoked programmatically */ private onFullscreenChange = () => { if ( @@ -2444,84 +2513,119 @@ class App extends React.Component { } }; + private removeEventListeners() { + this.onRemoveEventListenersEmitter.trigger(); + } + private addEventListeners() { + // remove first as we can add event listeners multiple times this.removeEventListeners(); - window.addEventListener(EVENT.MESSAGE, this.onWindowMessage, false); - document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553 - document.addEventListener(EVENT.COPY, this.onCopy); - this.excalidrawContainerRef.current?.addEventListener( - EVENT.WHEEL, - this.onWheel, - { passive: false }, - ); + + // ------------------------------------------------------------------------- + // view+edit mode listeners + // ------------------------------------------------------------------------- if (this.props.handleKeyboardGlobally) { - document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false); + this.onRemoveEventListenersEmitter.once( + addEventListener(document, EVENT.KEYDOWN, this.onKeyDown, false), + ); } - document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true }); - document.addEventListener( - EVENT.MOUSE_MOVE, - this.updateCurrentCursorPosition, - ); - // rerender text elements on font load to fix #637 && #1553 - document.fonts?.addEventListener?.("loadingdone", (event) => { - const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onFontsLoaded(loadedFontFaces); - }); - // Safari-only desktop pinch zoom - document.addEventListener( - EVENT.GESTURE_START, - this.onGestureStart as any, - false, - ); - document.addEventListener( - EVENT.GESTURE_CHANGE, - this.onGestureChange as any, - false, - ); - document.addEventListener( - EVENT.GESTURE_END, - this.onGestureEnd as any, - false, + this.onRemoveEventListenersEmitter.once( + addEventListener( + this.excalidrawContainerRef.current, + EVENT.WHEEL, + this.onWheel, + { passive: false }, + ), + addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false), + addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553 + addEventListener(document, EVENT.COPY, this.onCopy), + addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }), + addEventListener( + document, + EVENT.MOUSE_MOVE, + this.updateCurrentCursorPosition, + ), + // rerender text elements on font load to fix #637 && #1553 + addEventListener(document.fonts, "loadingdone", (event) => { + const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; + this.fonts.onFontsLoaded(loadedFontFaces); + }), + // Safari-only desktop pinch zoom + addEventListener( + document, + EVENT.GESTURE_START, + this.onGestureStart as any, + false, + ), + addEventListener( + document, + EVENT.GESTURE_CHANGE, + this.onGestureChange as any, + false, + ), + addEventListener( + document, + EVENT.GESTURE_END, + this.onGestureEnd as any, + false, + ), + addEventListener(window, EVENT.FOCUS, () => { + this.maybeCleanupAfterMissingPointerUp(null); + }), ); + if (this.state.viewModeEnabled) { return; } - document.addEventListener(EVENT.FULLSCREENCHANGE, this.onFullscreenChange); - document.addEventListener(EVENT.PASTE, this.pasteFromClipboard); - document.addEventListener(EVENT.CUT, this.onCut); + // ------------------------------------------------------------------------- + // edit-mode listeners only + // ------------------------------------------------------------------------- + + this.onRemoveEventListenersEmitter.once( + addEventListener( + document, + EVENT.FULLSCREENCHANGE, + this.onFullscreenChange, + ), + addEventListener(document, EVENT.PASTE, this.pasteFromClipboard), + addEventListener(document, EVENT.CUT, this.onCut), + addEventListener(window, EVENT.RESIZE, this.onResize, false), + addEventListener(window, EVENT.UNLOAD, this.onUnload, false), + addEventListener(window, EVENT.BLUR, this.onBlur, false), + addEventListener( + this.excalidrawContainerRef.current, + EVENT.DRAG_OVER, + this.disableEvent, + false, + ), + addEventListener( + this.excalidrawContainerRef.current, + EVENT.DROP, + this.disableEvent, + false, + ), + ); + if (this.props.detectScroll) { - this.nearestScrollableContainer = getNearestScrollableContainer( - this.excalidrawContainerRef.current!, - ); - this.nearestScrollableContainer.addEventListener( - EVENT.SCROLL, - this.onScroll, + this.onRemoveEventListenersEmitter.once( + addEventListener( + getNearestScrollableContainer(this.excalidrawContainerRef.current!), + EVENT.SCROLL, + this.onScroll, + ), ); } - window.addEventListener(EVENT.RESIZE, this.onResize, false); - window.addEventListener(EVENT.UNLOAD, this.onUnload, false); - window.addEventListener(EVENT.BLUR, this.onBlur, false); - this.excalidrawContainerRef.current?.addEventListener( - EVENT.DRAG_OVER, - this.disableEvent, - false, - ); - this.excalidrawContainerRef.current?.addEventListener( - EVENT.DROP, - this.disableEvent, - false, - ); } componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); - if ( - !this.state.showWelcomeScreen && - !this.scene.getElementsIncludingDeleted().length - ) { + const elements = this.scene.getElementsIncludingDeleted(); + const elementsMap = this.scene.getNonDeletedElementsMap(); + + if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); } @@ -2532,11 +2636,45 @@ class App extends React.Component { this.refreshEditorBreakpoints(); } + const hasFollowedPersonLeft = + prevState.userToFollow && + !this.state.collaborators.has(prevState.userToFollow.socketId); + + if (hasFollowedPersonLeft) { + this.maybeUnfollowRemoteUser(); + } + if ( + prevState.zoom.value !== this.state.zoom.value || prevState.scrollX !== this.state.scrollX || prevState.scrollY !== this.state.scrollY ) { - this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY); + this.props?.onScrollChange?.( + this.state.scrollX, + this.state.scrollY, + this.state.zoom, + ); + this.onScrollChangeEmitter.trigger( + this.state.scrollX, + this.state.scrollY, + this.state.zoom, + ); + } + + if (prevState.userToFollow !== this.state.userToFollow) { + if (prevState.userToFollow) { + this.onUserFollowEmitter.trigger({ + userToFollow: prevState.userToFollow, + action: "UNFOLLOW", + }); + } + + if (this.state.userToFollow) { + this.onUserFollowEmitter.trigger({ + userToFollow: this.state.userToFollow, + action: "FOLLOW", + }); + } } if ( @@ -2565,6 +2703,10 @@ class App extends React.Component { this.updateLanguage(); } + if (isEraserActive(prevState) && !isEraserActive(this.state)) { + this.eraserTrail.endPath(); + } + if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) { this.setState({ viewModeEnabled: !!this.props.viewModeEnabled }); } @@ -2588,15 +2730,9 @@ class App extends React.Component { }); } - if (this.props.name && prevProps.name !== this.props.name) { - this.setState({ - name: this.props.name, - }); - } - this.excalidrawContainerRef.current?.classList.toggle( "theme--dark", - this.state.theme === "dark", + this.state.theme === THEME.DARK, ); if ( @@ -2638,39 +2774,32 @@ class App extends React.Component { maybeBindLinearElement( multiElement, this.state, - this.scene, tupleToCoors( LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, + elementsMap, ), ), + this, ); } - this.history.record(this.state, this.scene.getElementsIncludingDeleted()); + this.history.record(this.state, elements); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during // init, which would trigger onChange with empty elements, which would then // override whatever is in localStorage currently. if (!this.state.isLoading) { - this.props.onChange?.( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); - this.onChangeEmitter.trigger( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); + this.props.onChange?.(elements, this.state, this.files); + this.onChangeEmitter.trigger(elements, this.state, this.files); } } private renderInteractiveSceneCallback = ({ atLeastOneVisibleElement, scrollBars, - elements, + elementsMap, }: RenderInteractiveSceneCallback) => { if (scrollBars) { currentScrollBars = scrollBars; @@ -2679,7 +2808,7 @@ class App extends React.Component { // hide when editing text isTextElement(this.state.editingElement) ? false - : !atLeastOneVisibleElement && elements.length > 0; + : !atLeastOneVisibleElement && elementsMap.size > 0; if (this.state.scrolledOutside !== scrolledOutside) { this.setState({ scrolledOutside }); } @@ -2728,9 +2857,8 @@ class App extends React.Component { } private onTouchStart = (event: TouchEvent) => { - // fix for Apple Pencil Scribble - // On Android, preventing the event would disable contextMenu on tap-hold - if (!isAndroid) { + // fix for Apple Pencil Scribble (do not prevent for other devices) + if (isIOS) { event.preventDefault(); } @@ -2755,9 +2883,6 @@ class App extends React.Component { didTapTwice = false; clearTimeout(tappedTwiceTimer); } - if (isAndroid) { - event.preventDefault(); - } if (event.touches.length === 2) { this.setState({ @@ -2818,7 +2943,6 @@ class App extends React.Component { // event else some browsers (FF...) will clear the clipboardData // (something something security) let file = event?.clipboardData?.files[0]; - const data = await parseClipboard(event, isPlainPaste); if (!file && !isPlainPaste) { if (data.mixedContent) { @@ -2894,21 +3018,49 @@ class App extends React.Component { retainSeed: isPlainPaste, }); } else if (data.text) { - const maybeUrl = extractSrc(data.text); + const nonEmptyLines = normalizeEOL(data.text) + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + + const embbeddableUrls = nonEmptyLines + .map((str) => maybeParseEmbedSrc(str)) + .filter((string) => { + return ( + embeddableURLValidator(string, this.props.validateEmbeddable) && + (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || + getEmbedLink(string)?.type === "video") + ); + }); if ( - !isPlainPaste && - embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) && - (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) || - getEmbedLink(maybeUrl)?.type === "video") + !IS_PLAIN_PASTE && + embbeddableUrls.length > 0 && + // if there were non-embeddable text (lines) mixed in with embeddable + // urls, ignore and paste as text + embbeddableUrls.length === nonEmptyLines.length ) { - const embeddable = this.insertEmbeddableElement({ - sceneX, - sceneY, - link: normalizeLink(maybeUrl), - }); - if (embeddable) { - this.setState({ selectedElementIds: { [embeddable.id]: true } }); + const embeddables: NonDeleted[] = []; + for (const url of embbeddableUrls) { + const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = + embeddables[embeddables.length - 1]; + const embeddable = this.insertEmbeddableElement({ + sceneX: prevEmbeddable + ? prevEmbeddable.x + prevEmbeddable.width + 20 + : sceneX, + sceneY, + link: normalizeLink(url), + }); + if (embeddable) { + embeddables.push(embeddable); + } + } + if (embeddables.length) { + this.setState({ + selectedElementIds: Object.fromEntries( + embeddables.map((embeddable) => [embeddable.id, true]), + ), + }); } return; } @@ -2967,17 +3119,34 @@ class App extends React.Component { }, ); - const nextElements = [ - ...this.scene.getElementsIncludingDeleted(), - ...newElements, - ]; + const prevElements = this.scene.getElementsIncludingDeleted(); + const nextElements = [...prevElements, ...newElements]; + + syncMovedIndices(nextElements, arrayToMap(newElements)); + + const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y }); + + if (topLayerFrame) { + const eligibleElements = filterElementsEligibleAsFrameChildren( + newElements, + topLayerFrame, + ); + addElementsToFrame(nextElements, eligibleElements, topLayerFrame); + } this.scene.replaceAllElements(nextElements); newElements.forEach((newElement) => { if (isTextElement(newElement) && isBoundToContainer(newElement)) { - const container = getContainerElement(newElement); - redrawTextBoundingBox(newElement, container); + const container = getContainerElement( + newElement, + this.scene.getElementsMapIncludingDeleted(), + ); + redrawTextBoundingBox( + newElement, + container, + this.scene.getElementsMapIncludingDeleted(), + ); } }); @@ -3060,7 +3229,13 @@ class App extends React.Component { try { return { file: await ImageURLToFile(url) }; } catch (error: any) { - return { errorMessage: error.message as string }; + let errorMessage = error.message; + if (error.cause === "FETCH_ERROR") { + errorMessage = t("errors.failedToFetchImage"); + } else if (error.cause === "UNSUPPORTED") { + errorMessage = t("errors.unsupportedFileType"); + } + return { errorMessage }; } }), ); @@ -3190,19 +3365,7 @@ class App extends React.Component { return; } - const frameId = textElements[0].frameId; - - if (frameId) { - this.scene.insertElementsAtIndex( - textElements, - this.scene.getElementIndex(frameId), - ); - } else { - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - ...textElements, - ]); - } + this.scene.insertElements(textElements); this.setState({ selectedElementIds: makeNextSelectedElementIds( @@ -3321,7 +3484,7 @@ class App extends React.Component { }); }; - private cancelInProgresAnimation: (() => void) | null = null; + private cancelInProgressAnimation: (() => void) | null = null; scrollToContent = ( target: @@ -3346,7 +3509,7 @@ class App extends React.Component { duration?: number; }, ) => { - this.cancelInProgresAnimation?.(); + this.cancelInProgressAnimation?.(); // convert provided target into ExcalidrawElement[] if necessary const targetElements = Array.isArray(target) ? target : [target]; @@ -3413,20 +3576,27 @@ class App extends React.Component { duration: opts?.duration ?? 500, }); - this.cancelInProgresAnimation = () => { + this.cancelInProgressAnimation = () => { cancel(); - this.cancelInProgresAnimation = null; + this.cancelInProgressAnimation = null; }; } else { this.setState({ scrollX, scrollY, zoom }); } }; + private maybeUnfollowRemoteUser = () => { + if (this.state.userToFollow) { + this.setState({ userToFollow: null }); + } + }; + /** use when changing scrollX/scrollY/zoom based on user interaction */ private translateCanvas: React.Component["setState"] = ( state, ) => { - this.cancelInProgresAnimation?.(); + this.cancelInProgressAnimation?.(); + this.maybeUnfollowRemoteUser(); this.setState(state); }; @@ -3519,17 +3689,29 @@ class App extends React.Component { tab, force, }: { - name: SidebarName; + name: SidebarName | null; tab?: SidebarTabName; force?: boolean; }): boolean => { let nextName; if (force === undefined) { - nextName = this.state.openSidebar?.name === name ? null : name; + nextName = + this.state.openSidebar?.name === name && + this.state.openSidebar?.tab === tab + ? null + : name; } else { nextName = force ? name : null; } - this.setState({ openSidebar: nextName ? { name: nextName, tab } : null }); + + const nextState: AppState["openSidebar"] = nextName + ? { name: nextName } + : null; + if (nextState && tab) { + nextState.tab = tab; + } + + this.setState({ openSidebar: nextState }); return !!nextName; }; @@ -3569,6 +3751,21 @@ class App extends React.Component { }); } + if ( + event[KEYS.CTRL_OR_CMD] && + event.key === KEYS.P && + !event.shiftKey && + !event.altKey + ) { + this.setToast({ + message: t("commandPalette.shortcutHint", { + shortcut: getShortcutFromShortcutName("commandPalette"), + }), + }); + event.preventDefault(); + return; + } + if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) { IS_PLAIN_PASTE = event.shiftKey; clearTimeout(IS_PLAIN_PASTE_TIMER); @@ -3680,7 +3877,7 @@ class App extends React.Component { y: element.y + offsetY, }); - updateBoundElements(element, { + updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { simultaneouslyUpdated: selectedElements, }); }); @@ -3703,7 +3900,6 @@ class App extends React.Component { this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, - this.scene, ), }); } @@ -3716,7 +3912,11 @@ class App extends React.Component { if (!isTextElement(selectedElement)) { container = selectedElement as ExcalidrawTextContainer; } - const midPoint = getContainerCenter(selectedElement, this.state); + const midPoint = getContainerCenter( + selectedElement, + this.state, + this.scene.getNonDeletedElementsMap(), + ); const sceneX = midPoint.x; const sceneY = midPoint.y; this.startTextEditing({ @@ -3850,9 +4050,10 @@ class App extends React.Component { } if (isArrowKey(event.key)) { const selectedElements = this.scene.getSelectedElements(this.state); + const elementsMap = this.scene.getNonDeletedElementsMap(); isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements(selectedElements) - : unbindLinearElements(selectedElements); + ? bindOrUnbindSelectedElements(selectedElements, this) + : unbindLinearElements(selectedElements, elementsMap); this.setState({ suggestedBindings: [] }); } }); @@ -3954,6 +4155,14 @@ class App extends React.Component { return gesture.pointers.size >= 2; }; + public getName = () => { + return ( + this.state.name || + this.props.name || + `${t("labels.untitled")}-${getDateTime()}` + ); + }; + // fires only on Safari private onGestureStart = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); @@ -4025,19 +4234,27 @@ class App extends React.Component { isExistingElement?: boolean; }, ) { + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const updateElement = ( text: string, originalText: string, isDeleted: boolean, ) => { this.scene.replaceAllElements([ + // Not sure why we include deleted elements as well hence using deleted elements map ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { - return updateTextElement(_element, { - text, - isDeleted, - originalText, - }); + return updateTextElement( + _element, + getContainerElement(_element, elementsMap), + elementsMap, + { + text, + isDeleted, + originalText, + }, + ); } return _element; }), @@ -4063,7 +4280,7 @@ class App extends React.Component { onChange: withBatchedUpdates((text) => { updateElement(text, text, false); if (isNonDeletedElement(element)) { - updateBoundElements(element); + updateBoundElements(element, elementsMap); } }), onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { @@ -4138,12 +4355,88 @@ class App extends React.Component { return null; } + /** + * get the pure geometric shape of an excalidraw element + * which is then used for hit detection + */ + public getElementShape(element: ExcalidrawElement): GeometricShape { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + + return shouldTestInside(element) + ? getClosedCurveShape( + element, + roughShape, + [element.x, element.y], + element.angle, + [cx, cy], + ) + : getCurveShape(roughShape, [element.x, element.y], element.angle, [ + cx, + cy, + ]); + } + + case "ellipse": + return getEllipseShape(element); + + case "freedraw": { + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + } + } + } + + private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null { + const boundTextElement = getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ); + + if (boundTextElement) { + if (element.type === "arrow") { + return this.getElementShape({ + ...boundTextElement, + // arrow's bound text accurate position is not stored in the element's property + // but rather calculated and returned from the following static method + ...LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + this.scene.getNonDeletedElementsMap(), + ), + }); + } + return this.getElementShape(boundTextElement); + } + + return null; + } + private getElementAtPosition( x: number, y: number, opts?: { - /** if true, returns the first selected element (with highest z-index) - of all hit elements */ preferSelected?: boolean; includeBoundTextElement?: boolean; includeLockedElements?: boolean; @@ -4155,6 +4448,7 @@ class App extends React.Component { opts?.includeBoundTextElement, opts?.includeLockedElements, ); + if (allHitElements.length > 1) { if (opts?.preferSelected) { for (let index = allHitElements.length - 1; index > -1; index--) { @@ -4165,21 +4459,20 @@ class App extends React.Component { } const elementWithHighestZIndex = allHitElements[allHitElements.length - 1]; + // If we're hitting element with highest z-index only on its bounding box // while also hitting other element figure, the latter should be considered. - return isHittingElementBoundingBoxWithoutHittingElement( - elementWithHighestZIndex, - this.state, - this.frameNameBoundsCache, - x, - y, + return isPointInShape( + [x, y], + this.getElementShape(elementWithHighestZIndex), ) - ? allHitElements[allHitElements.length - 2] - : elementWithHighestZIndex; + ? elementWithHighestZIndex + : allHitElements[allHitElements.length - 2]; } if (allHitElements.length === 1) { return allHitElements[0]; } + return null; } @@ -4189,7 +4482,11 @@ class App extends React.Component { includeBoundTextElement: boolean = false, includeLockedElements: boolean = false, ): NonDeleted[] { - const elements = + const iframeLikes: Ordered[] = []; + + const elementsMap = this.scene.getNonDeletedElementsMap(); + + const elements = ( includeBoundTextElement && includeLockedElements ? this.scene.getNonDeletedElements() : this.scene @@ -4199,21 +4496,120 @@ class App extends React.Component { (includeLockedElements || !element.locked) && (includeBoundTextElement || !(isTextElement(element) && element.containerId)), - ); + ) + ) + .filter((el) => this.hitElement(x, y, el)) + .filter((element) => { + // hitting a frame's element from outside the frame is not considered a hit + const containingFrame = getContainingFrame(element, elementsMap); + return containingFrame && + this.state.frameRendering.enabled && + this.state.frameRendering.clip + ? isCursorInFrame({ x, y }, containingFrame, elementsMap) + : true; + }) + .filter((el) => { + // The parameter elements comes ordered from lower z-index to higher. + // We want to preserve that order on the returned array. + // Exception being embeddables which should be on top of everything else in + // terms of hit testing. + if (isIframeElement(el)) { + iframeLikes.push(el); + return false; + } + return true; + }) + .concat(iframeLikes) as NonDeleted[]; - return getElementsAtPosition(elements, (element) => - hitTest(element, this.state, this.frameNameBoundsCache, x, y), - ).filter((element) => { - // hitting a frame's element from outside the frame is not considered a hit - const containingFrame = getContainingFrame(element); - return containingFrame && - this.state.frameRendering.enabled && - this.state.frameRendering.clip - ? isCursorInFrame({ x, y }, containingFrame) - : true; + return elements; + } + + private getHitThreshold() { + return 10 / this.state.zoom.value; + } + + private hitElement( + x: number, + y: number, + element: ExcalidrawElement, + considerBoundingBox = true, + ) { + // if the element is selected, then hit test is done against its bounding box + if ( + considerBoundingBox && + this.state.selectedElementIds[element.id] && + shouldShowBoundingBox([element], this.state) + ) { + return hitElementBoundingBox( + x, + y, + element, + this.scene.getNonDeletedElementsMap(), + this.getHitThreshold(), + ); + } + + // take bound text element into consideration for hit collision as well + const hitBoundTextOfElement = hitElementBoundText( + x, + y, + this.getBoundTextShape(element), + ); + if (hitBoundTextOfElement) { + return true; + } + + return hitElementItself({ + x, + y, + element, + shape: this.getElementShape(element), + threshold: this.getHitThreshold(), + frameNameBound: isFrameLikeElement(element) + ? this.frameNameBoundsCache.get(element) + : null, }); } + private getTextBindableContainerAtPosition(x: number, y: number) { + const elements = this.scene.getNonDeletedElements(); + const selectedElements = this.scene.getSelectedElements(this.state); + if (selectedElements.length === 1) { + return isTextBindableContainer(selectedElements[0], false) + ? selectedElements[0] + : null; + } + let hitElement = null; + // We need to do hit testing from front (end of the array) to back (beginning of the array) + for (let index = elements.length - 1; index >= 0; --index) { + if (elements[index].isDeleted) { + continue; + } + const [x1, y1, x2, y2] = getElementAbsoluteCoords( + elements[index], + this.scene.getNonDeletedElementsMap(), + ); + if ( + isArrowElement(elements[index]) && + hitElementItself({ + x, + y, + element: elements[index], + shape: this.getElementShape(elements[index]), + threshold: this.getHitThreshold(), + }) + ) { + hitElement = elements[index]; + break; + } else if (x1 < x && x < x2 && y1 < y && y < y2) { + hitElement = elements[index]; + break; + } + } + + return isTextBindableContainer(hitElement, false) ? hitElement : null; + } + private startTextEditing = ({ sceneX, sceneY, @@ -4239,7 +4635,10 @@ class App extends React.Component { container, ); if (container && parentCenterPosition) { - const boundTextElementToContainer = getBoundTextElement(container); + const boundTextElementToContainer = getBoundTextElement( + container, + this.scene.getNonDeletedElementsMap(), + ); if (!boundTextElementToContainer) { shouldBindToContainer = true; } @@ -4252,7 +4651,10 @@ class App extends React.Component { if (isTextElement(selectedElements[0])) { existingTextElement = selectedElements[0]; } else if (container) { - existingTextElement = getBoundTextElement(selectedElements[0]); + existingTextElement = getBoundTextElement( + selectedElements[0], + this.scene.getNonDeletedElementsMap(), + ); } else { existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } @@ -4349,7 +4751,7 @@ class App extends React.Component { const containerIndex = this.scene.getElementIndex(container.id); this.scene.insertElementAtIndex(element, containerIndex + 1); } else { - this.scene.addNewElement(element); + this.scene.insertElement(element); } } @@ -4385,17 +4787,9 @@ class App extends React.Component { ) { this.history.resumeRecording(); this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElements[0], - this.scene, - ), + editingLinearElement: new LinearElementEditor(selectedElements[0]), }); return; - } else if ( - this.state.editingLinearElement && - this.state.editingLinearElement.elementId === selectedElements[0].id - ) { - return; } } @@ -4443,25 +4837,25 @@ class App extends React.Component { return; } - const container = getTextBindableContainerAtPosition( - this.scene.getNonDeletedElements(), - this.state, - sceneX, - sceneY, - ); + const container = this.getTextBindableContainerAtPosition(sceneX, sceneY); if (container) { if ( hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || - isHittingElementNotConsideringBoundingBox( + hitElementItself({ + x: sceneX, + y: sceneY, + element: container, + shape: this.getElementShape(container), + threshold: this.getHitThreshold(), + }) + ) { + const midPoint = getContainerCenter( container, this.state, - this.frameNameBoundsCache, - [sceneX, sceneY], - ) - ) { - const midPoint = getContainerCenter(container, this.state); + this.scene.getNonDeletedElementsMap(), + ); sceneX = midPoint.x; sceneY = midPoint.y; @@ -4495,6 +4889,7 @@ class App extends React.Component { index <= hitElementIndex && isPointHittingLink( element, + this.scene.getNonDeletedElementsMap(), this.state, [scenePointer.x, scenePointer.y], this.device.editor.isMobile, @@ -4525,8 +4920,10 @@ class App extends React.Component { this.lastPointerDownEvent!, this.state, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const lastPointerDownHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerDownCoords.x, lastPointerDownCoords.y], this.device.editor.isMobile, @@ -4537,6 +4934,7 @@ class App extends React.Component { ); const lastPointerUpHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerUpCoords.x, lastPointerUpCoords.y], this.device.editor.isMobile, @@ -4573,10 +4971,11 @@ class App extends React.Component { x: number; y: number; }) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame), + isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; @@ -4586,6 +4985,7 @@ class App extends React.Component { event: React.PointerEvent, ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); + this.lastPointerMoveEvent = event.nativeEvent; if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { @@ -4679,15 +5079,34 @@ class App extends React.Component { y: scenePointerY, }, event, + this.scene.getNonDeletedElementsMap(), ); - this.setState({ - snapLines, - originSnapOffset: originOffset, + this.setState((prevState) => { + const nextSnapLines = updateStable(prevState.snapLines, snapLines); + const nextOriginOffset = prevState.originSnapOffset + ? updateStable(prevState.originSnapOffset, originOffset) + : originOffset; + + if ( + prevState.snapLines === nextSnapLines && + prevState.originSnapOffset === nextOriginOffset + ) { + return null; + } + return { + snapLines: nextSnapLines, + originSnapOffset: nextOriginOffset, + }; }); } else if (!this.state.draggingElement) { - this.setState({ - snapLines: [], + this.setState((prevState) => { + if (prevState.snapLines.length) { + return { + snapLines: [], + }; + } + return null; }); } @@ -4700,6 +5119,7 @@ class App extends React.Component { scenePointerX, scenePointerY, this.state, + this.scene.getNonDeletedElementsMap(), ); if ( @@ -4850,6 +5270,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), ); if ( elementWithTransformHandleType && @@ -4897,7 +5318,11 @@ class App extends React.Component { !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - showHyperlinkTooltip(this.hitLinkElement, this.state); + showHyperlinkTooltip( + this.hitLinkElement, + this.state, + this.scene.getNonDeletedElementsMap(), + ); } else { hideHyperlinkToolip(); if ( @@ -4967,30 +5392,48 @@ class App extends React.Component { pointerDownState: PointerDownState, scenePointer: { x: number; y: number }, ) => { - const updateElementIds = (elements: ExcalidrawElement[]) => { - elements.forEach((element) => { + this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y); + + let didChange = false; + + const processedGroups = new Set(); + const nonDeletedElements = this.scene.getNonDeletedElements(); + + const processElements = (elements: ExcalidrawElement[]) => { + for (const element of elements) { if (element.locked) { return; } - idsToUpdate.push(element.id); if (event.altKey) { - if ( - pointerDownState.elementIdsToErase[element.id] && - pointerDownState.elementIdsToErase[element.id].erase - ) { - pointerDownState.elementIdsToErase[element.id].erase = false; + if (this.elementsPendingErasure.delete(element.id)) { + didChange = true; } - } else if (!pointerDownState.elementIdsToErase[element.id]) { - pointerDownState.elementIdsToErase[element.id] = { - erase: true, - opacity: element.opacity, - }; + } else if (!this.elementsPendingErasure.has(element.id)) { + didChange = true; + this.elementsPendingErasure.add(element.id); } - }); - }; - const idsToUpdate: Array = []; + // (un)erase groups atomically + if (didChange && element.groupIds?.length) { + const shallowestGroupId = element.groupIds.at(-1)!; + if (!processedGroups.has(shallowestGroupId)) { + processedGroups.add(shallowestGroupId); + const elems = getElementsInGroup( + nonDeletedElements, + shallowestGroupId, + ); + for (const elem of elems) { + if (event.altKey) { + this.elementsPendingErasure.delete(elem.id); + } else { + this.elementsPendingErasure.add(elem.id); + } + } + } + } + } + }; const distance = distance2d( pointerDownState.lastCoords.x, @@ -4998,12 +5441,12 @@ class App extends React.Component { scenePointer.x, scenePointer.y, ); - const threshold = 10 / this.state.zoom.value; + const threshold = this.getHitThreshold(); const point = { ...pointerDownState.lastCoords }; let samplingInterval = 0; while (samplingInterval <= distance) { const hitElements = this.getElementsAtPosition(point.x, point.y); - updateElementIds(hitElements); + processElements(hitElements); // Exit since we reached current point if (samplingInterval === distance) { @@ -5022,35 +5465,31 @@ class App extends React.Component { point.y = nextY; } - const elements = this.scene.getElementsIncludingDeleted().map((ele) => { - const id = - isBoundToContainer(ele) && idsToUpdate.includes(ele.containerId) - ? ele.containerId - : ele.id; - if (idsToUpdate.includes(id)) { - if (event.altKey) { - if ( - pointerDownState.elementIdsToErase[id] && - pointerDownState.elementIdsToErase[id].erase === false - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[id].opacity, - }); - } - } else { - return newElementWith(ele, { - opacity: ELEMENT_READY_TO_ERASE_OPACITY, - }); - } - } - return ele; - }); - - this.scene.replaceAllElements(elements); - pointerDownState.lastCoords.x = scenePointer.x; pointerDownState.lastCoords.y = scenePointer.y; + + if (didChange) { + for (const element of this.scene.getNonDeletedElements()) { + if ( + isBoundToContainer(element) && + (this.elementsPendingErasure.has(element.id) || + this.elementsPendingErasure.has(element.containerId)) + ) { + if (event.altKey) { + this.elementsPendingErasure.delete(element.id); + this.elementsPendingErasure.delete(element.containerId); + } else { + this.elementsPendingErasure.add(element.id); + this.elementsPendingErasure.add(element.containerId); + } + } + } + + this.elementsPendingErasure = new Set(this.elementsPendingErasure); + this.onSceneUpdated(); + } }; + // set touch moving for mobile context menu private handleTouchMove = (event: React.TouchEvent) => { invalidateContextMenu = true; @@ -5061,12 +5500,13 @@ class App extends React.Component { scenePointerX: number, scenePointerY: number, ) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); - const boundTextElement = getBoundTextElement(element); - if (!element) { return; } @@ -5074,15 +5514,16 @@ class App extends React.Component { let hoverPointIndex = -1; let segmentMidPointHoveredCoords = null; if ( - isHittingElementNotConsideringBoundingBox( + hitElementItself({ + x: scenePointerX, + y: scenePointerY, element, - this.state, - this.frameNameBoundsCache, - [scenePointerX, scenePointerY], - ) + shape: this.getElementShape(element), + }) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, this.state.zoom, scenePointerX, scenePointerY, @@ -5092,6 +5533,7 @@ class App extends React.Component { linearElementEditor, { x: scenePointerX, y: scenePointerY }, this.state, + this.scene.getNonDeletedElementsMap(), ); if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { @@ -5099,27 +5541,7 @@ class App extends React.Component { } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } - } else if ( - shouldShowBoundingBox([element], this.state) && - isHittingElementBoundingBoxWithoutHittingElement( - element, - this.state, - this.frameNameBoundsCache, - scenePointerX, - scenePointerY, - ) - ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); - } else if ( - boundTextElement && - hitTest( - boundTextElement, - this.state, - this.frameNameBoundsCache, - scenePointerX, - scenePointerY, - ) - ) { + } else if (this.hitElement(scenePointerX, scenePointerY, element)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } @@ -5155,6 +5577,9 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); + this.maybeUnfollowRemoteUser(); + // since contextMenu options are potentially evaluated on each render, // and an contextMenu action may depend on selection state, we must // close the contextMenu before we update the selection on pointerDown @@ -5215,7 +5640,6 @@ class App extends React.Component { selection.removeAllRanges(); } this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event); - this.maybeCleanupAfterMissingPointerUp(event); //fires only once, if pen is detected, penMode is enabled //the user can disable this by toggling the penMode button @@ -5254,10 +5678,60 @@ class App extends React.Component { }); this.savePointer(event.clientX, event.clientY, "down"); + if ( + event.button === POINTER_BUTTON.ERASER && + this.state.activeTool.type !== TOOL_TYPE.eraser + ) { + this.setState( + { + activeTool: updateActiveTool(this.state, { + type: TOOL_TYPE.eraser, + lastActiveToolBeforeEraser: this.state.activeTool, + }), + }, + () => { + this.handleCanvasPointerDown(event); + const onPointerUp = () => { + unsubPointerUp(); + unsubCleanup?.(); + if (isEraserActive(this.state)) { + this.setState({ + activeTool: updateActiveTool(this.state, { + ...(this.state.activeTool.lastActiveTool || { + type: TOOL_TYPE.selection, + }), + lastActiveToolBeforeEraser: null, + }), + }); + } + }; + + const unsubPointerUp = addEventListener( + window, + EVENT.POINTER_UP, + onPointerUp, + { + once: true, + }, + ); + let unsubCleanup: UnsubscribeCallback | undefined; + // subscribe inside rAF lest it'd be triggered on the same pointerdown + // if we start erasing while coming from blurred document since + // we cleanup pointer events on focus + requestAnimationFrame(() => { + unsubCleanup = + this.missingPointerEventCleanupEmitter.once(onPointerUp); + }); + }, + ); + return; + } + // only handle left mouse button or touch if ( event.button !== POINTER_BUTTON.MAIN && - event.button !== POINTER_BUTTON.TOUCH + event.button !== POINTER_BUTTON.TOUCH && + event.button !== POINTER_BUTTON.ERASER ) { return; } @@ -5355,7 +5829,7 @@ class App extends React.Component { this.state.activeTool.type, ); } else if (this.state.activeTool.type === "laser") { - this.laserPathManager.startPath( + this.laserTrails.startPath( pointerDownState.lastCoords.x, pointerDownState.lastCoords.y, ); @@ -5376,6 +5850,13 @@ class App extends React.Component { event, ); + if (this.state.activeTool.type === "eraser") { + this.eraserTrail.startPath( + pointerDownState.lastCoords.x, + pointerDownState.lastCoords.y, + ); + } + const onPointerMove = this.onPointerMoveFromPointerDownHandler(pointerDownState); @@ -5385,7 +5866,9 @@ class App extends React.Component { const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState); const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState); - lastPointerUp = onPointerUp; + this.missingPointerEventCleanupEmitter.once((_event) => + onPointerUp(_event || event.nativeEvent), + ); if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") { window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); @@ -5448,10 +5931,12 @@ class App extends React.Component { if ( clicklength < 300 && isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon(this.hitLinkElement, this.state, [ - scenePointer.x, - scenePointer.y, - ]) + !isPointHittingLinkIcon( + this.hitLinkElement, + this.scene.getNonDeletedElementsMap(), + this.state, + [scenePointer.x, scenePointer.y], + ) ) { this.handleEmbeddableCenterClick(this.hitLinkElement); } else { @@ -5496,16 +5981,15 @@ class App extends React.Component { invalidateContextMenu = false; }; - private maybeCleanupAfterMissingPointerUp( - event: React.PointerEvent, - ): void { - if (lastPointerUp !== null) { - // Unfortunately, sometimes we don't get a pointerup after a pointerdown, - // this can happen when a contextual menu or alert is triggered. In order to avoid - // being in a weird state, we clean up on the next pointerdown - lastPointerUp(event); - } - } + /** + * pointerup may not fire in certian cases (user tabs away...), so in order + * to properly cleanup pointerdown state, we need to fire any hanging + * pointerup handlers manually + */ + private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => { + lastPointerUp?.(); + this.missingPointerEventCleanupEmitter.trigger(event).clear(); + }; // Returns whether the event is a panning private handleCanvasPanUsingWheelOrSpaceDrag = ( @@ -5527,7 +6011,10 @@ class App extends React.Component { event.preventDefault(); let nextPastePrevented = false; - const isLinux = /Linux/.test(window.navigator.platform); + const isLinux = + typeof window === undefined + ? false + : /Linux/.test(window.navigator.platform); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING); let { clientX: lastX, clientY: lastY } = event; @@ -5683,7 +6170,6 @@ class App extends React.Component { boxSelection: { hasOccurred: false, }, - elementIdsToErase: {}, }; } @@ -5708,11 +6194,10 @@ class App extends React.Component { this.handlePointerMoveOverScrollbars(event, pointerDownState); }); - const onPointerUp = withBatchedUpdates(() => { + lastPointerUp = null; isDraggingScrollBar = false; setCursorForShape(this.interactiveCanvas, this.state); - lastPointerUp = null; this.setState({ cursorButton: "up", }); @@ -5749,7 +6234,9 @@ class App extends React.Component { ): boolean => { if (this.state.activeTool.type === "selection") { const elements = this.scene.getNonDeletedElements(); + const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); + if (selectedElements.length === 1 && !this.state.editingLinearElement) { const elementWithTransformHandleType = getElementWithTransformHandleType( @@ -5759,6 +6246,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), ); if (elementWithTransformHandleType != null) { this.setState({ @@ -5782,6 +6270,7 @@ class App extends React.Component { getResizeOffsetXY( pointerDownState.resize.handleType, selectedElements, + elementsMap, pointerDownState.origin.x, pointerDownState.origin.y, ), @@ -5806,6 +6295,7 @@ class App extends React.Component { this.history, pointerDownState.origin, linearElementEditor, + this, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6028,7 +6518,7 @@ class App extends React.Component { } // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / this.state.zoom.value; + const threshold = this.getHitThreshold(); const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( point.x > x1 - threshold && @@ -6056,12 +6546,7 @@ class App extends React.Component { }); // FIXME - let container = getTextBindableContainerAtPosition( - this.scene.getNonDeletedElements(), - this.state, - sceneX, - sceneY, - ); + let container = this.getTextBindableContainerAtPosition(sceneX, sceneY); if (hasBoundTextElement(element)) { container = element as ExcalidrawTextContainer; @@ -6141,9 +6626,9 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this, ); - this.scene.addNewElement(element); + this.scene.insertElement(element); this.setState({ draggingElement: element, editingElement: element, @@ -6188,10 +6673,7 @@ class App extends React.Component { height, }); - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - element, - ]); + this.scene.insertElement(element); return element; }; @@ -6220,8 +6702,11 @@ class App extends React.Component { return; } - if (embedLink.warning) { - this.setToast({ message: embedLink.warning, closable: true }); + if (embedLink.error instanceof URIError) { + this.setToast({ + message: t("toast.unrecognizedLinkFormat"), + closable: true, + }); } const element = newEmbeddableElement({ @@ -6240,13 +6725,9 @@ class App extends React.Component { width: embedLink.intrinsicSize.w, height: embedLink.intrinsicSize.h, link, - validated: null, }); - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - element, - ]); + this.scene.insertElement(element); return element; }; @@ -6407,10 +6888,10 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this, ); - this.scene.addNewElement(element); + this.scene.insertElement(element); this.setState({ draggingElement: element, editingElement: element, @@ -6474,7 +6955,6 @@ class App extends React.Component { if (elementType === "embeddable") { element = newEmbeddableElement({ type: "embeddable", - validated: null, ...baseElementAttributes, }); } else { @@ -6490,7 +6970,7 @@ class App extends React.Component { draggingElement: element, }); } else { - this.scene.addNewElement(element); + this.scene.insertElement(element); this.setState({ multiElement: null, draggingElement: element, @@ -6524,10 +7004,7 @@ class App extends React.Component { ? newMagicFrameElement(constructorOpts) : newFrameElement(constructorOpts); - this.scene.replaceAllElements([ - ...this.scene.getElementsIncludingDeleted(), - frame, - ]); + this.scene.insertElement(frame); this.setState({ multiElement: null, @@ -6554,6 +7031,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6577,6 +7055,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6640,7 +7119,7 @@ class App extends React.Component { } if (this.state.activeTool.type === "laser") { - this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y); + this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y); } const [gridX, gridY] = getGridPoint( @@ -6676,6 +7155,7 @@ class App extends React.Component { return true; } } + const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { const linearElementEditor = @@ -6686,6 +7166,7 @@ class App extends React.Component { this.state.selectedLinearElement, pointerCoords, this.state, + elementsMap, ) ) { const ret = LinearElementEditor.addMidpoint( @@ -6693,6 +7174,7 @@ class App extends React.Component { pointerCoords, this.state, !event[KEYS.CTRL_OR_CMD], + elementsMap, ); if (!ret) { return; @@ -6743,6 +7225,7 @@ class App extends React.Component { ); }, linearElementEditor, + this.scene.getNonDeletedElementsMap(), ); if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; @@ -6850,10 +7333,11 @@ class App extends React.Component { this.maybeCacheReferenceSnapPoints(event, selectedElements); const { snapOffset, snapLines } = snapDraggedElements( - getSelectedElements(originalElements, this.state), + originalElements, dragOffset, this.state, event, + this.scene.getNonDeletedElementsMap(), ); this.setState({ snapLines }); @@ -6933,7 +7417,11 @@ class App extends React.Component { nextElements.push(element); } } + const nextSceneElements = [...nextElements, ...elementsToAppend]; + + syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend)); + bindTextToShapeAfterDuplication( nextElements, elementsToAppend, @@ -6950,6 +7438,7 @@ class App extends React.Component { elementsToAppend, oldIdToDuplicatedId, ); + this.scene.replaceAllElements(nextSceneElements); this.maybeCacheVisibleGaps(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true); @@ -7037,6 +7526,7 @@ class App extends React.Component { event, this.state, this.setState.bind(this), + this.scene.getNonDeletedElementsMap(), ); // regular box-select } else { @@ -7067,6 +7557,7 @@ class App extends React.Component { const elementsWithinSelection = getElementsWithinSelection( elements, draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -7109,10 +7600,7 @@ class App extends React.Component { selectedLinearElement: elementsWithinSelection.length === 1 && isLinearElement(elementsWithinSelection[0]) - ? new LinearElementEditor( - elementsWithinSelection[0], - this.scene, - ) + ? new LinearElementEditor(elementsWithinSelection[0]) : null, showHyperlinkPopup: elementsWithinSelection.length === 1 && @@ -7158,6 +7646,7 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + this.removePointer(childEvent); if (pointerDownState.eventListeners.onMove) { pointerDownState.eventListeners.onMove.flush(); } @@ -7170,7 +7659,7 @@ class App extends React.Component { isRotating, } = this.state; - this.setState({ + this.setState((prevState) => ({ isResizing: false, isRotating: false, resizingElement: null, @@ -7184,10 +7673,9 @@ class App extends React.Component { multiElement || isTextElement(this.state.editingElement) ? this.state.editingElement : null, - snapLines: [], - + snapLines: updateStable(prevState.snapLines, []), originSnapOffset: null, - }); + })); SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -7197,7 +7685,7 @@ class App extends React.Component { this.setState({ selectedElementsAreBeingDragged: false, }); - + const elementsMap = this.scene.getNonDeletedElementsMap(); // Handle end of dragging a point of a linear element, might close a loop // and sets binding element if (this.state.editingLinearElement) { @@ -7212,6 +7700,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, + this, ); if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ @@ -7235,6 +7724,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, + this, ); const { startBindingElement, endBindingElement } = @@ -7245,6 +7735,7 @@ class App extends React.Component { element, startBindingElement, endBindingElement, + elementsMap, ); } @@ -7260,7 +7751,7 @@ class App extends React.Component { } } - lastPointerUp = null; + this.missingPointerEventCleanupEmitter.clear(); window.removeEventListener( EVENT.POINTER_MOVE, @@ -7283,6 +7774,7 @@ class App extends React.Component { this.setState({ pendingImageElementId: null }); } + this.props?.onPointerUp?.(activeTool, pointerDownState); this.onPointerUpEmitter.trigger( this.state.activeTool, pointerDownState, @@ -7381,8 +7873,8 @@ class App extends React.Component { maybeBindLinearElement( draggingElement, this.state, - this.scene, pointerCoords, + this, ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -7400,10 +7892,7 @@ class App extends React.Component { }, prevState, ), - selectedLinearElement: new LinearElementEditor( - draggingElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(draggingElement), })); } else { this.setState((prevState) => ({ @@ -7450,23 +7939,28 @@ class App extends React.Component { ); if (linearElement?.frameId) { - const frame = getContainingFrame(linearElement); + const frame = getContainingFrame(linearElement, elementsMap); if (frame && linearElement) { - if (!elementOverlapsWithFrame(linearElement, frame)) { + if ( + !elementOverlapsWithFrame( + linearElement, + frame, + this.scene.getNonDeletedElementsMap(), + ) + ) { // remove the linear element from all groups // before removing it from the frame as well mutateElement(linearElement, { groupIds: [], }); - this.scene.replaceAllElements( - removeElementsFromFrame( - this.scene.getElementsIncludingDeleted(), - [linearElement], - this.state, - ), + removeElementsFromFrame( + [linearElement], + this.scene.getNonDeletedElementsMap(), ); + + this.scene.informMutation(); } } } @@ -7476,7 +7970,7 @@ class App extends React.Component { this.getTopLayerFrameAtSceneCoords(sceneCoords); const selectedElements = this.scene.getSelectedElements(this.state); - let nextElements = this.scene.getElementsIncludingDeleted(); + let nextElements = this.scene.getElementsMapIncludingDeleted(); const updateGroupIdsAfterEditingGroup = ( elements: ExcalidrawElement[], @@ -7565,11 +8059,12 @@ class App extends React.Component { const elementsInsideFrame = getElementsInNewFrame( this.scene.getElementsIncludingDeleted(), draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.scene.replaceAllElements( addElementsToFrame( - this.scene.getElementsIncludingDeleted(), + this.scene.getElementsMapIncludingDeleted(), elementsInsideFrame, draggingElement, ), @@ -7615,9 +8110,10 @@ class App extends React.Component { this.scene.getElementsIncludingDeleted(), frame, this.state, + elementsMap, ), frame, - this.state, + this, ); } @@ -7636,26 +8132,29 @@ class App extends React.Component { // the one we've hit if (selectedELements.length === 1) { this.setState({ - selectedLinearElement: new LinearElementEditor( - hitElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(hitElement), }); } } - if (isEraserActive(this.state)) { + + const pointerStart = this.lastPointerDownEvent; + const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; + + if (isEraserActive(this.state) && pointerStart && pointerEnd) { + this.eraserTrail.endPath(); + const draggedDistance = distance2d( - this.lastPointerDownEvent!.clientX, - this.lastPointerDownEvent!.clientY, - this.lastPointerUpEvent!.clientX, - this.lastPointerUpEvent!.clientY, + pointerStart.clientX, + pointerStart.clientY, + pointerEnd.clientX, + pointerEnd.clientY, ); if (draggedDistance === 0) { const scenePointer = viewportCoordsToSceneCoords( { - clientX: this.lastPointerUpEvent!.clientX, - clientY: this.lastPointerUpEvent!.clientY, + clientX: pointerEnd.clientX, + clientY: pointerEnd.clientY, }, this.state, ); @@ -7663,18 +8162,14 @@ class App extends React.Component { scenePointer.x, scenePointer.y, ); - hitElements.forEach( - (hitElement) => - (pointerDownState.elementIdsToErase[hitElement.id] = { - erase: true, - opacity: hitElement.opacity, - }), + hitElements.forEach((hitElement) => + this.elementsPendingErasure.add(hitElement.id), ); } - this.eraseElements(pointerDownState); + this.eraseElements(); return; - } else if (Object.keys(pointerDownState.elementIdsToErase).length) { - this.restoreReadyToEraseElements(pointerDownState); + } else if (this.elementsPendingErasure.size) { + this.restoreReadyToEraseElements(); } if ( @@ -7721,7 +8216,7 @@ class App extends React.Component { ), }; }); - // if not gragging a linear element point (outside editor) + // if not dragging a linear element point (outside editor) } else if (!this.state.selectedLinearElement?.isDragging) { // remove element from selection while // keeping prev elements selected @@ -7750,10 +8245,7 @@ class App extends React.Component { selectedLinearElement: newSelectedElements.length === 1 && isLinearElement(newSelectedElements[0]) - ? new LinearElementEditor( - newSelectedElements[0], - this.scene, - ) + ? new LinearElementEditor(newSelectedElements[0]) : prevState.selectedLinearElement, }; }); @@ -7827,22 +8319,31 @@ class App extends React.Component { // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1. // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized prevState.selectedLinearElement?.elementId !== hitElement.id - ? new LinearElementEditor(hitElement, this.scene) + ? new LinearElementEditor(hitElement) : prevState.selectedLinearElement, })); } } if ( + // not dragged !pointerDownState.drag.hasOccurred && + // not resized !this.state.isResizing && + // only hitting the bounding box of the previous hit element ((hitElement && - isHittingElementBoundingBoxWithoutHittingElement( - hitElement, - this.state, - this.frameNameBoundsCache, - pointerDownState.origin.x, - pointerDownState.origin.y, + hitElementBoundingBoxOnly( + { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + element: hitElement, + shape: this.getElementShape(hitElement), + threshold: this.getHitThreshold(), + frameNameBound: isFrameLikeElement(hitElement) + ? this.frameNameBoundsCache.get(hitElement) + : null, + }, + elementsMap, )) || (!hitElement && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) @@ -7858,6 +8359,8 @@ class App extends React.Component { activeEmbeddable: null, }); } + // reset cursor + setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); return; } @@ -7890,13 +8393,19 @@ class App extends React.Component { } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { - (isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements - : unbindLinearElements)(this.scene.getSelectedElements(this.state)); + isBindingEnabled(this.state) + ? bindOrUnbindSelectedElements( + this.scene.getSelectedElements(this.state), + this, + ) + : unbindLinearElements( + this.scene.getNonDeletedElements(), + elementsMap, + ); } if (activeTool.type === "laser") { - this.laserPathManager.endPath(); + this.laserTrails.endPath(); return; } @@ -7935,65 +8444,32 @@ class App extends React.Component { }); } - private restoreReadyToEraseElements = ( - pointerDownState: PointerDownState, - ) => { - const elements = this.scene.getElementsIncludingDeleted().map((ele) => { - if ( - pointerDownState.elementIdsToErase[ele.id] && - pointerDownState.elementIdsToErase[ele.id].erase - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[ele.id].opacity, - }); - } else if ( - isBoundToContainer(ele) && - pointerDownState.elementIdsToErase[ele.containerId] && - pointerDownState.elementIdsToErase[ele.containerId].erase - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[ele.containerId].opacity, - }); - } else if ( - ele.frameId && - pointerDownState.elementIdsToErase[ele.frameId] && - pointerDownState.elementIdsToErase[ele.frameId].erase - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[ele.frameId].opacity, - }); - } - return ele; - }); - - this.scene.replaceAllElements(elements); + private restoreReadyToEraseElements = () => { + this.elementsPendingErasure = new Set(); + this.onSceneUpdated(); }; - private eraseElements = (pointerDownState: PointerDownState) => { + private eraseElements = () => { + let didChange = false; const elements = this.scene.getElementsIncludingDeleted().map((ele) => { if ( - pointerDownState.elementIdsToErase[ele.id] && - pointerDownState.elementIdsToErase[ele.id].erase - ) { - return newElementWith(ele, { isDeleted: true }); - } else if ( - isBoundToContainer(ele) && - pointerDownState.elementIdsToErase[ele.containerId] && - pointerDownState.elementIdsToErase[ele.containerId].erase - ) { - return newElementWith(ele, { isDeleted: true }); - } else if ( - ele.frameId && - pointerDownState.elementIdsToErase[ele.frameId] && - pointerDownState.elementIdsToErase[ele.frameId].erase + this.elementsPendingErasure.has(ele.id) || + (ele.frameId && this.elementsPendingErasure.has(ele.frameId)) || + (isBoundToContainer(ele) && + this.elementsPendingErasure.has(ele.containerId)) ) { + didChange = true; return newElementWith(ele, { isDeleted: true }); } return ele; }); - this.history.resumeRecording(); - this.scene.replaceAllElements(elements); + this.elementsPendingErasure = new Set(); + + if (didChange) { + this.history.resumeRecording(); + this.scene.replaceAllElements(elements); + } }; private initializeImage = async ({ @@ -8046,7 +8522,10 @@ class App extends React.Component { maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, }); } catch (error: any) { - console.error("error trying to resing image file on insertion", error); + console.error( + "Error trying to resizing image file on insertion", + error, + ); } if (imageFile.size > MAX_ALLOWED_FILE_BYTES) { @@ -8134,7 +8613,7 @@ class App extends React.Component { return; } - this.scene.addNewElement(imageElement); + this.scene.insertElement(imageElement); try { return await this.initializeImage({ @@ -8158,10 +8637,18 @@ class App extends React.Component { // mustn't be larger than 128 px // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property const cursorImageSizePx = 96; + let imagePreview; - const imagePreview = await resizeImageFile(imageFile, { - maxWidthOrHeight: cursorImageSizePx, - }); + try { + imagePreview = await resizeImageFile(imageFile, { + maxWidthOrHeight: cursorImageSizePx, + }); + } catch (e: any) { + if (e.cause === "UNSUPPORTED") { + throw new Error(t("errors.unsupportedFileType")); + } + throw e; + } let previewDataURL = await getDataURL(imagePreview); @@ -8399,7 +8886,7 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, - this.scene, + this, ); this.setState({ suggestedBindings: @@ -8426,7 +8913,7 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, - this.scene, + this, ); if ( hoveredBindableElement != null && @@ -8452,7 +8939,10 @@ class App extends React.Component { if (selectedElements.length > 50) { return; } - const suggestedBindings = getEligibleElementsForBinding(selectedElements); + const suggestedBindings = getEligibleElementsForBinding( + selectedElements, + this, + ); this.setState({ suggestedBindings }); } @@ -8544,8 +9034,9 @@ class App extends React.Component { }); return; } catch (error: any) { + // Don't throw for image scene daa if (error.name !== "EncodingError") { - throw error; + throw new Error(t("alerts.couldNotLoadInvalidFile")); } } } @@ -8589,7 +9080,7 @@ class App extends React.Component { } if (file) { - // atetmpt to parse an excalidraw/excalidrawlib file + // Attempt to parse an excalidraw/excalidrawlib file await this.loadFileToCanvas(file, fileHandle); } @@ -8619,12 +9110,39 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { - const ret = await loadSceneOrLibraryFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); + let ret; + try { + ret = await loadSceneOrLibraryFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + fileHandle, + ); + } catch (error: any) { + const imageSceneDataError = error instanceof ImageSceneDataError; + if ( + imageSceneDataError && + error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && + !this.isToolSupported("image") + ) { + this.setState({ + isLoading: false, + errorMessage: t("errors.imageToolNotSupported"), + }); + return; + } + const errorMessage = imageSceneDataError + ? t("alerts.cannotRestoreFromImage") + : t("alerts.couldNotLoadInvalidFile"); + this.setState({ + isLoading: false, + errorMessage, + }); + } + if (!ret) { + return; + } + if (ret.type === MIME_TYPES.excalidraw) { this.setState({ isLoading: true }); this.syncActionResult({ @@ -8649,17 +9167,6 @@ class App extends React.Component { }); } } catch (error: any) { - if ( - error instanceof ImageSceneDataError && - error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && - !this.isToolSupported("image") - ) { - this.setState({ - isLoading: false, - errorMessage: t("errors.imageToolNotSupported"), - }); - return; - } this.setState({ isLoading: false, errorMessage: error.message }); } }; @@ -8688,13 +9195,13 @@ class App extends React.Component { }); const selectedElements = this.scene.getSelectedElements(this.state); - const isHittignCommonBoundBox = + const isHittingCommonBoundBox = this.isHittingCommonBoundingBoxOfSelectedElements( { x, y }, selectedElements, ); - const type = element || isHittignCommonBoundBox ? "element" : "canvas"; + const type = element || isHittingCommonBoundBox ? "element" : "canvas"; const container = this.excalidrawContainerRef.current!; const { top: offsetTop, left: offsetLeft } = @@ -8719,7 +9226,7 @@ class App extends React.Component { this, ), selectedLinearElement: isLinearElement(element) - ? new LinearElementEditor(element, this.scene) + ? new LinearElementEditor(element) : null, } : this.state), @@ -8791,6 +9298,7 @@ class App extends React.Component { x: gridX - pointerDownState.originInGrid.x, y: gridY - pointerDownState.originInGrid.y, }, + this.scene.getNonDeletedElementsMap(), ); gridX += snapOffset.x; @@ -8829,6 +9337,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), draggingElement as ExcalidrawFrameLikeElement, this.state, + this.scene.getNonDeletedElementsMap(), ), }); } @@ -8925,10 +9434,10 @@ class App extends React.Component { if ( transformElements( - pointerDownState, + pointerDownState.originalElements, transformHandleType, selectedElements, - pointerDownState.resize.arrowDirection, + this.scene.getElementsMapIncludingDeleted(), shouldRotateWithDiscreteAngle(event), shouldResizeFromCenter(event), selectedElements.length === 1 && isImageElement(selectedElements[0]) @@ -8938,7 +9447,6 @@ class App extends React.Component { resizeY, pointerDownState.resize.center.x, pointerDownState.resize.center.y, - this.state, ) ) { this.maybeSuggestBindingForAll(selectedElements); @@ -8949,6 +9457,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), frame, this.state, + this.scene.getNonDeletedElementsMap(), ).forEach((element) => elementsToHighlight.add(element)); }); @@ -9115,7 +9624,11 @@ class App extends React.Component { let elementCenterX = container.x + container.width / 2; let elementCenterY = container.y + container.height / 2; - const elementCenter = getContainerCenter(container, appState); + const elementCenter = getContainerCenter( + container, + appState, + this.scene.getNonDeletedElementsMap(), + ); if (elementCenter) { elementCenterX = elementCenter.x; elementCenterY = elementCenter.y; @@ -9241,7 +9754,6 @@ class App extends React.Component { // ----------------------------------------------------------------------------- // TEST HOOKS // ----------------------------------------------------------------------------- - declare global { interface Window { h: { @@ -9254,20 +9766,25 @@ declare global { } } -if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { - window.h = window.h || ({} as Window["h"]); +export const createTestHook = () => { + if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + window.h = window.h || ({} as Window["h"]); - Object.defineProperties(window.h, { - elements: { - configurable: true, - get() { - return this.app?.scene.getElementsIncludingDeleted(); + Object.defineProperties(window.h, { + elements: { + configurable: true, + get() { + return this.app?.scene.getElementsIncludingDeleted(); + }, + set(elements: ExcalidrawElement[]) { + return this.app?.scene.replaceAllElements( + syncInvalidIndices(elements), + ); + }, }, - set(elements: ExcalidrawElement[]) { - return this.app?.scene.replaceAllElements(elements); - }, - }, - }); -} + }); + } +}; +createTestHook(); export default App; diff --git a/packages/excalidraw/components/Avatar.scss b/packages/excalidraw/components/Avatar.scss index c0c66f0a2..6565816e3 100644 --- a/packages/excalidraw/components/Avatar.scss +++ b/packages/excalidraw/components/Avatar.scss @@ -1,35 +1,7 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .Avatar { - width: 1.25rem; - height: 1.25rem; - position: relative; - border-radius: 100%; - outline-offset: 2px; - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - font-size: 0.75rem; - font-weight: 800; - line-height: 1; - - &-img { - width: 100%; - height: 100%; - border-radius: 100%; - } - - &::before { - content: ""; - position: absolute; - top: -3px; - right: -3px; - bottom: -3px; - left: -3px; - border: 1px solid var(--avatar-border-color); - border-radius: 100%; - } + @include avatarStyles; } } diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index 8b4624b7f..9ddc319c6 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -2,21 +2,29 @@ import "./Avatar.scss"; import React, { useState } from "react"; import { getNameInitial } from "../clients"; +import clsx from "clsx"; type AvatarProps = { onClick: (e: React.MouseEvent) => void; color: string; name: string; src?: string; + className?: string; }; -export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { +export const Avatar = ({ + color, + onClick, + name, + src, + className, +}: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
+
{loadImg ? ( (null); + +export const DEFAULT_CATEGORIES = { + app: "App", + export: "Export", + tools: "Tools", + editor: "Editor", + elements: "Elements", + links: "Links", +}; + +const getCategoryOrder = (category: string) => { + switch (category) { + case DEFAULT_CATEGORIES.app: + return 1; + case DEFAULT_CATEGORIES.export: + return 2; + case DEFAULT_CATEGORIES.editor: + return 3; + case DEFAULT_CATEGORIES.tools: + return 4; + case DEFAULT_CATEGORIES.elements: + return 5; + case DEFAULT_CATEGORIES.links: + return 6; + default: + return 10; + } +}; + +const CommandShortcutHint = ({ + shortcut, + className, + children, +}: { + shortcut: string; + className?: string; + children?: React.ReactNode; +}) => { + const shortcuts = shortcut.replace("++", "+$").split("+"); + + return ( +
+ {shortcuts.map((item, idx) => { + return ( +
+
{item === "$" ? "+" : item}
+
+ ); + })} +
{children}
+
+ ); +}; + +const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => { + return ( + !event.altKey && + event[KEYS.CTRL_OR_CMD] && + ((event.shiftKey && event.key.toLowerCase() === KEYS.P) || + event.key === KEYS.SLASH) + ); +}; + +type CommandPaletteProps = { + customCommandPaletteItems?: CommandPaletteItem[]; +}; + +export const CommandPalette = Object.assign( + (props: CommandPaletteProps) => { + const uiAppState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + useEffect(() => { + const commandPaletteShortcut = (event: KeyboardEvent) => { + if (isCommandPaletteToggleShortcut(event)) { + event.preventDefault(); + event.stopPropagation(); + setAppState((appState) => { + const nextState = + appState.openDialog?.name === "commandPalette" + ? null + : ({ name: "commandPalette" } as const); + + if (nextState) { + trackEvent("command_palette", "open", "shortcut"); + } + + return { + openDialog: nextState, + }; + }); + } + }; + window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, { + capture: true, + }); + return () => + window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, { + capture: true, + }); + }, [setAppState]); + + if (uiAppState.openDialog?.name !== "commandPalette") { + return null; + } + + return ; + }, + { + defaultItems, + }, +); + +function CommandPaletteInner({ + customCommandPaletteItems, +}: CommandPaletteProps) { + const app = useApp(); + const uiAppState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + const appProps = useAppProps(); + const actionManager = useExcalidrawActionManager(); + + const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem); + const [allCommands, setAllCommands] = useState< + MarkRequired[] | null + >(null); + + const inputRef = useRef(null); + + const stableDeps = useStable({ + uiAppState, + customCommandPaletteItems, + appProps, + }); + + useEffect(() => { + // these props change often and we don't want them to re-run the effect + // which would renew `allCommands`, cascading down and resetting state. + // + // This means that the commands won't update on appState/appProps changes + // while the command palette is open + const { uiAppState, customCommandPaletteItems, appProps } = stableDeps; + + const getActionLabel = (action: Action) => { + let label = ""; + if (action.label) { + if (typeof action.label === "function") { + label = t( + action.label( + app.scene.getNonDeletedElements(), + uiAppState as AppState, + app, + ) as unknown as TranslationKeys, + ); + } else { + label = t(action.label as unknown as TranslationKeys); + } + } + return label; + }; + + const getActionIcon = (action: Action) => { + if (typeof action.icon === "function") { + return action.icon(uiAppState, app.scene.getNonDeletedElements()); + } + return action.icon; + }; + + let commandsFromActions: CommandPaletteItem[] = []; + + const actionToCommand = ( + action: Action, + category: string, + transformer?: ( + command: CommandPaletteItem, + action: Action, + ) => CommandPaletteItem, + ): CommandPaletteItem => { + const command: CommandPaletteItem = { + label: getActionLabel(action), + icon: getActionIcon(action), + category, + shortcut: getShortcutFromShortcutName(action.name as ShortcutName), + keywords: action.keywords, + predicate: action.predicate, + viewMode: action.viewMode, + perform: () => { + actionManager.executeAction(action, "commandPalette"); + }, + }; + + return transformer ? transformer(command, action) : command; + }; + + if (uiAppState && app.scene && actionManager) { + const elementsCommands: CommandPaletteItem[] = [ + actionManager.actions.group, + actionManager.actions.ungroup, + actionManager.actions.cut, + actionManager.actions.copy, + actionManager.actions.deleteSelectedElements, + actionManager.actions.copyStyles, + actionManager.actions.pasteStyles, + actionManager.actions.sendBackward, + actionManager.actions.sendToBack, + actionManager.actions.bringForward, + actionManager.actions.bringToFront, + actionManager.actions.alignTop, + actionManager.actions.alignBottom, + actionManager.actions.alignLeft, + actionManager.actions.alignRight, + actionManager.actions.alignVerticallyCentered, + actionManager.actions.alignHorizontallyCentered, + actionManager.actions.duplicateSelection, + actionManager.actions.flipHorizontal, + actionManager.actions.flipVertical, + actionManager.actions.zoomToFitSelection, + actionManager.actions.zoomToFitSelectionInViewport, + actionManager.actions.increaseFontSize, + actionManager.actions.decreaseFontSize, + actionManager.actions.toggleLinearEditor, + actionLink, + ].map((action: Action) => + actionToCommand( + action, + DEFAULT_CATEGORIES.elements, + (command, action) => ({ + ...command, + predicate: action.predicate + ? action.predicate + : (elements, appState, appProps, app) => { + const selectedElements = getSelectedElements( + elements, + appState, + ); + return selectedElements.length > 0; + }, + }), + ), + ); + const toolCommands: CommandPaletteItem[] = [ + actionManager.actions.toggleHandTool, + actionManager.actions.setFrameAsActiveTool, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools)); + + const editorCommands: CommandPaletteItem[] = [ + actionManager.actions.undo, + actionManager.actions.redo, + actionManager.actions.zoomIn, + actionManager.actions.zoomOut, + actionManager.actions.resetZoom, + actionManager.actions.zoomToFit, + actionManager.actions.zenMode, + actionManager.actions.viewMode, + actionManager.actions.objectsSnapMode, + actionManager.actions.toggleShortcuts, + actionManager.actions.selectAll, + actionManager.actions.toggleElementLock, + actionManager.actions.unlockAllElements, + actionManager.actions.stats, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor)); + + const exportCommands: CommandPaletteItem[] = [ + actionManager.actions.saveToActiveFile, + actionManager.actions.saveFileToDisk, + actionManager.actions.copyAsPng, + actionManager.actions.copyAsSvg, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export)); + + commandsFromActions = [ + ...elementsCommands, + ...editorCommands, + { + label: getActionLabel(actionClearCanvas), + icon: getActionIcon(actionClearCanvas), + shortcut: getShortcutFromShortcutName( + actionClearCanvas.name as ShortcutName, + ), + category: DEFAULT_CATEGORIES.editor, + keywords: ["delete", "destroy"], + viewMode: false, + perform: () => { + jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); + }, + }, + { + label: t("buttons.exportImage"), + category: DEFAULT_CATEGORIES.export, + icon: ExportImageIcon, + shortcut: getShortcutFromShortcutName("imageExport"), + keywords: [ + "export", + "image", + "png", + "jpeg", + "svg", + "clipboard", + "picture", + ], + perform: () => { + setAppState({ openDialog: { name: "imageExport" } }); + }, + }, + ...exportCommands, + ]; + + const additionalCommands: CommandPaletteItem[] = [ + { + label: t("toolBar.library"), + category: DEFAULT_CATEGORIES.app, + icon: LibraryIcon, + viewMode: false, + perform: () => { + if (uiAppState.openSidebar) { + setAppState({ + openSidebar: null, + }); + } else { + setAppState({ + openSidebar: { + name: DEFAULT_SIDEBAR.name, + tab: DEFAULT_SIDEBAR.defaultTab, + }, + }); + } + }, + }, + { + label: t("labels.changeStroke"), + keywords: ["color", "outline"], + category: DEFAULT_CATEGORIES.elements, + icon: bucketFillIcon, + viewMode: false, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length > 0 && + canChangeStrokeColor(appState, selectedElements) + ); + }, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "shape" ? null : "shape", + openPopup: "elementStroke", + })); + }, + }, + { + label: t("labels.changeBackground"), + keywords: ["color", "fill"], + icon: bucketFillIcon, + category: DEFAULT_CATEGORIES.elements, + viewMode: false, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length > 0 && + canChangeBackgroundColor(appState, selectedElements) + ); + }, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "shape" ? null : "shape", + openPopup: "elementBackground", + })); + }, + }, + { + label: t("labels.canvasBackground"), + keywords: ["color"], + icon: bucketFillIcon, + category: DEFAULT_CATEGORIES.editor, + viewMode: false, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "canvas" ? null : "canvas", + openPopup: "canvasBackground", + })); + }, + }, + ...SHAPES.reduce((acc: CommandPaletteItem[], shape) => { + const { value, icon, key, numericKey } = shape; + + if ( + appProps.UIOptions.tools?.[ + value as Extract< + typeof value, + keyof AppProps["UIOptions"]["tools"] + > + ] === false + ) { + return acc; + } + + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter || numericKey; + + const command: CommandPaletteItem = { + label: t(`toolBar.${value}`), + category: DEFAULT_CATEGORIES.tools, + shortcut, + icon, + keywords: ["toolbar"], + viewMode: false, + perform: ({ event }) => { + if (value === "image") { + app.setActiveTool({ + type: value, + insertOnCanvasDirectly: event.type === EVENT.KEYDOWN, + }); + } else { + app.setActiveTool({ type: value }); + } + }, + }; + + acc.push(command); + + return acc; + }, []), + ...toolCommands, + { + label: t("toolBar.lock"), + category: DEFAULT_CATEGORIES.tools, + icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon, + shortcut: KEYS.Q.toLocaleUpperCase(), + viewMode: false, + perform: () => { + app.toggleLock(); + }, + }, + { + label: `${t("labels.textToDiagram")}...`, + category: DEFAULT_CATEGORIES.tools, + icon: brainIconThin, + viewMode: false, + predicate: appProps.aiEnabled, + perform: () => { + setAppState((state) => ({ + ...state, + openDialog: { + name: "ttd", + tab: "text-to-diagram", + }, + })); + }, + }, + { + label: `${t("toolBar.mermaidToExcalidraw")}...`, + category: DEFAULT_CATEGORIES.tools, + icon: mermaidLogoIcon, + viewMode: false, + predicate: appProps.aiEnabled, + perform: () => { + setAppState((state) => ({ + ...state, + openDialog: { + name: "ttd", + tab: "mermaid", + }, + })); + }, + }, + // { + // label: `${t("toolBar.magicframe")}...`, + // category: DEFAULT_CATEGORIES.tools, + // icon: MagicIconThin, + // viewMode: false, + // predicate: appProps.aiEnabled, + // perform: () => { + // app.onMagicframeToolSelect(); + // }, + // }, + ]; + + const allCommands = [ + ...commandsFromActions, + ...additionalCommands, + ...(customCommandPaletteItems || []), + ].map((command) => { + return { + ...command, + icon: command.icon || boltIcon, + order: command.order ?? getCategoryOrder(command.category), + haystack: `${deburr(command.label)} ${ + command.keywords?.join(" ") || "" + }`, + }; + }); + + setAllCommands(allCommands); + setLastUsed( + allCommands.find((command) => command.label === lastUsed?.label) ?? + null, + ); + } + }, [ + stableDeps, + app, + actionManager, + setAllCommands, + lastUsed?.label, + setLastUsed, + setAppState, + ]); + + const [commandSearch, setCommandSearch] = useState(""); + const [currentCommand, setCurrentCommand] = + useState(null); + const [commandsByCategory, setCommandsByCategory] = useState< + Record + >({}); + + const closeCommandPalette = (cb?: () => void) => { + setAppState( + { + openDialog: null, + }, + cb, + ); + setCommandSearch(""); + }; + + const executeCommand = ( + command: CommandPaletteItem, + event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent, + ) => { + if (uiAppState.openDialog?.name === "commandPalette") { + event.stopPropagation(); + event.preventDefault(); + document.body.classList.add("excalidraw-animations-disabled"); + closeCommandPalette(() => { + command.perform({ actionManager, event }); + setLastUsed(command); + + requestAnimationFrame(() => { + document.body.classList.remove("excalidraw-animations-disabled"); + }); + }); + } + }; + + const isCommandAvailable = useStableCallback( + (command: CommandPaletteItem) => { + if (command.viewMode === false && uiAppState.viewModeEnabled) { + return false; + } + + return typeof command.predicate === "function" + ? command.predicate( + app.scene.getNonDeletedElements(), + uiAppState as AppState, + appProps, + app, + ) + : command.predicate === undefined || command.predicate; + }, + ); + + const handleKeyDown = useStableCallback((event: KeyboardEvent) => { + const ignoreAlphanumerics = + isWritableElement(event.target) || + isCommandPaletteToggleShortcut(event) || + event.key === KEYS.ESCAPE; + + if ( + ignoreAlphanumerics && + event.key !== KEYS.ARROW_UP && + event.key !== KEYS.ARROW_DOWN && + event.key !== KEYS.ENTER + ) { + return; + } + + const matchingCommands = Object.values(commandsByCategory).flat(); + const shouldConsiderLastUsed = + lastUsed && !commandSearch && isCommandAvailable(lastUsed); + + if (event.key === KEYS.ARROW_UP) { + event.preventDefault(); + const index = matchingCommands.findIndex( + (item) => item.label === currentCommand?.label, + ); + + if (shouldConsiderLastUsed) { + if (index === 0) { + setCurrentCommand(lastUsed); + return; + } + + if (currentCommand === lastUsed) { + const nextItem = matchingCommands[matchingCommands.length - 1]; + if (nextItem) { + setCurrentCommand(nextItem); + } + return; + } + } + + let nextIndex; + + if (index === -1) { + nextIndex = matchingCommands.length - 1; + } else { + nextIndex = + index === 0 + ? matchingCommands.length - 1 + : (index - 1) % matchingCommands.length; + } + + const nextItem = matchingCommands[nextIndex]; + if (nextItem) { + setCurrentCommand(nextItem); + } + + return; + } + + if (event.key === KEYS.ARROW_DOWN) { + event.preventDefault(); + const index = matchingCommands.findIndex( + (item) => item.label === currentCommand?.label, + ); + + if (shouldConsiderLastUsed) { + if (!currentCommand || index === matchingCommands.length - 1) { + setCurrentCommand(lastUsed); + return; + } + + if (currentCommand === lastUsed) { + const nextItem = matchingCommands[0]; + if (nextItem) { + setCurrentCommand(nextItem); + } + return; + } + } + + const nextIndex = (index + 1) % matchingCommands.length; + const nextItem = matchingCommands[nextIndex]; + if (nextItem) { + setCurrentCommand(nextItem); + } + + return; + } + + if (event.key === KEYS.ENTER) { + if (currentCommand) { + setTimeout(() => { + executeCommand(currentCommand, event); + }); + } + } + + if (ignoreAlphanumerics) { + return; + } + + // prevent regular editor shortcuts + event.stopPropagation(); + + // if alphanumeric keypress and we're not inside the input, focus it + if (/^[a-zA-Z0-9]$/.test(event.key)) { + inputRef?.current?.focus(); + return; + } + + event.preventDefault(); + }); + + useEffect(() => { + window.addEventListener(EVENT.KEYDOWN, handleKeyDown, { + capture: true, + }); + return () => + window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, { + capture: true, + }); + }, [handleKeyDown]); + + useEffect(() => { + if (!allCommands) { + return; + } + + const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => { + const nextCommandsByCategory: Record = {}; + for (const command of commands) { + if (nextCommandsByCategory[command.category]) { + nextCommandsByCategory[command.category].push(command); + } else { + nextCommandsByCategory[command.category] = [command]; + } + } + + return nextCommandsByCategory; + }; + + let matchingCommands = allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); + + const showLastUsed = + !commandSearch && lastUsed && isCommandAvailable(lastUsed); + + if (!commandSearch) { + setCommandsByCategory( + getNextCommandsByCategory( + showLastUsed + ? matchingCommands.filter( + (command) => command.label !== lastUsed?.label, + ) + : matchingCommands, + ), + ); + setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null); + return; + } + + const _query = deburr(commandSearch.replace(/[<>-_| ]/g, "")); + matchingCommands = fuzzy + .filter(_query, matchingCommands, { + extract: (command) => command.haystack, + }) + .sort((a, b) => b.score - a.score) + .map((item) => item.original); + + setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); + setCurrentCommand(matchingCommands[0] ?? null); + }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + + return ( + closeCommandPalette()} + closeOnClickOutside + title={false} + size={720} + autofocus + className="command-palette-dialog" + > + { + setCommandSearch(value); + }} + selectOnRender + ref={inputRef} + /> + + {!app.device.viewport.isMobile && ( +
+ + {t("commandPalette.shortcuts.select")} + + + {t("commandPalette.shortcuts.confirm")} + + + {t("commandPalette.shortcuts.close")} + +
+ )} + +
+ {lastUsed && !commandSearch && ( +
+
+ {t("commandPalette.recents")} +
+ {clockIcon} +
+
+ executeCommand(lastUsed, event)} + disabled={!isCommandAvailable(lastUsed)} + onMouseMove={() => setCurrentCommand(lastUsed)} + showShortcut={!app.device.viewport.isMobile} + appState={uiAppState} + /> +
+ )} + + {Object.keys(commandsByCategory).length > 0 ? ( + Object.keys(commandsByCategory).map((category, idx) => { + return ( +
+
{category}
+ {commandsByCategory[category].map((command) => ( + executeCommand(command, event)} + onMouseMove={() => setCurrentCommand(command)} + showShortcut={!app.device.viewport.isMobile} + appState={uiAppState} + /> + ))} +
+ ); + }) + ) : allCommands ? ( +
+
{searchIcon}
{" "} + {t("commandPalette.search.noMatch")} +
+ ) : null} +
+
+ ); +} + +const CommandItem = ({ + command, + isSelected, + disabled, + onMouseMove, + onClick, + showShortcut, + appState, +}: { + command: CommandPaletteItem; + isSelected: boolean; + disabled?: boolean; + onMouseMove: () => void; + onClick: (event: React.MouseEvent) => void; + showShortcut: boolean; + appState: UIAppState; +}) => { + const noop = () => {}; + + return ( +
{ + if (isSelected && !disabled) { + ref?.scrollIntoView?.({ + block: "nearest", + }); + } + }} + onClick={disabled ? noop : onClick} + onMouseMove={disabled ? noop : onMouseMove} + title={disabled ? t("commandPalette.itemNotAvailable") : ""} + > +
+ {command.icon && ( + + )} + {command.label} +
+ {showShortcut && command.shortcut && ( + + )} +
+ ); +}; diff --git a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts new file mode 100644 index 000000000..831a585ae --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts @@ -0,0 +1,11 @@ +import { actionToggleTheme } from "../../actions"; +import { CommandPaletteItem } from "./types"; + +export const toggleTheme: CommandPaletteItem = { + ...actionToggleTheme, + category: "App", + label: "Toggle theme", + perform: ({ actionManager }) => { + actionManager.executeAction(actionToggleTheme, "commandPalette"); + }, +}; diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts new file mode 100644 index 000000000..59e306d2d --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -0,0 +1,26 @@ +import { ActionManager } from "../../actions/manager"; +import { Action } from "../../actions/types"; +import { UIAppState } from "../../types"; + +export type CommandPaletteItem = { + label: string; + /** additional keywords to match against + * (appended to haystack, not displayed) */ + keywords?: string[]; + /** + * string we should match against when searching + * (deburred name + keywords) + */ + haystack?: string; + icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode); + category: string; + order?: number; + predicate?: boolean | Action["predicate"]; + shortcut?: string; + /** if false, command will not show while in view mode */ + viewMode?: boolean; + perform: (data: { + actionManager: ActionManager; + event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent; + }) => void; +}; diff --git a/packages/excalidraw/components/ConfirmDialog.scss b/packages/excalidraw/components/ConfirmDialog.scss index 82a6dff9b..1fa36fc41 100644 --- a/packages/excalidraw/components/ConfirmDialog.scss +++ b/packages/excalidraw/components/ConfirmDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .confirm-dialog { diff --git a/packages/excalidraw/components/ContextMenu.scss b/packages/excalidraw/components/ContextMenu.scss index 81ced3880..3bedf2ba7 100644 --- a/packages/excalidraw/components/ContextMenu.scss +++ b/packages/excalidraw/components/ContextMenu.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .context-menu { diff --git a/packages/excalidraw/components/ContextMenu.tsx b/packages/excalidraw/components/ContextMenu.tsx index ebabae83b..23959a990 100644 --- a/packages/excalidraw/components/ContextMenu.tsx +++ b/packages/excalidraw/components/ContextMenu.tsx @@ -78,17 +78,17 @@ export const ContextMenu = React.memo( const actionName = item.name; let label = ""; - if (item.contextItemLabel) { - if (typeof item.contextItemLabel === "function") { + if (item.label) { + if (typeof item.label === "function") { label = t( - item.contextItemLabel( + item.label( elements, appState, actionManager.app, ) as unknown as TranslationKeys, ); } else { - label = t(item.contextItemLabel as unknown as TranslationKeys); + label = t(item.label as unknown as TranslationKeys); } } diff --git a/packages/excalidraw/components/DarkModeToggle.tsx b/packages/excalidraw/components/DarkModeToggle.tsx index 8a4bf261d..5ac8235c8 100644 --- a/packages/excalidraw/components/DarkModeToggle.tsx +++ b/packages/excalidraw/components/DarkModeToggle.tsx @@ -14,7 +14,9 @@ export const DarkModeToggle = (props: { }) => { const title = props.title || - (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")); + (props.value === THEME.DARK + ? t("buttons.lightMode") + : t("buttons.darkMode")); return ( { const focusableElements = queryFocusableElements(islandNode); - if (focusableElements.length > 0 && props.autofocus !== false) { - // If there's an element other than close, focus it. - (focusableElements[1] || focusableElements[0]).focus(); - } + setTimeout(() => { + if (focusableElements.length > 0 && props.autofocus !== false) { + // If there's an element other than close, focus it. + (focusableElements[1] || focusableElements[0]).focus(); + } + }); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.TAB) { @@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => { {props.title} )} - + {isFullscreen && ( + + )}
{props.children}
diff --git a/packages/excalidraw/components/ExportDialog.scss b/packages/excalidraw/components/ExportDialog.scss index 3cb31c484..d114f25be 100644 --- a/packages/excalidraw/components/ExportDialog.scss +++ b/packages/excalidraw/components/ExportDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .ExportDialog__preview { diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index c0a57f272..d23c9d104 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .ExcButton { @@ -10,11 +10,44 @@ background-color: var(--back-color); border-color: var(--border-color); + &:hover { + transition: all 150ms ease-out; + } + + .Spinner { + --spinner-color: var(--color-surface-lowest); + position: absolute; + visibility: visible; + } + + &[disabled] { + pointer-events: none; + + .ExcButton__contents { + visibility: hidden; + } + } + + &, + &__contents { + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + flex-wrap: nowrap; + // needed because of .Spinner + position: relative; + } + &--color-primary { &.ExcButton--variant-filled { --text-color: var(--color-surface-lowest); --back-color: var(--color-primary); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-brand-hover); } @@ -27,9 +60,13 @@ &.ExcButton--variant-outlined, &.ExcButton--variant-icon { --text-color: var(--color-primary); - --border-color: var(--color-border-outline); + --border-color: var(--color-primary); --back-color: transparent; + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-brand-hover); --border-color: var(--color-brand-hover); @@ -47,6 +84,10 @@ --text-color: var(--color-danger-text); --back-color: var(--color-danger-dark); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-danger-darker); } @@ -62,6 +103,10 @@ --border-color: var(--color-danger); --back-color: transparent; + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-danger-darkest); --border-color: var(--color-danger-darkest); @@ -79,6 +124,10 @@ --text-color: var(--island-bg-color); --back-color: var(--color-gray-50); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-gray-60); } @@ -94,6 +143,10 @@ --border-color: var(--color-muted); --back-color: var(--island-bg-color); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-muted-background-darker); --border-color: var(--color-muted-darker); @@ -111,6 +164,10 @@ --text-color: black; --back-color: var(--color-warning-dark); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-warning-darker); } @@ -126,6 +183,10 @@ --border-color: var(--color-warning-dark); --back-color: var(--input-bg-color); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-warning-darker); --border-color: var(--color-warning-darker); @@ -138,30 +199,25 @@ } } - display: flex; - justify-content: center; - align-items: center; - flex-shrink: 0; - flex-wrap: nowrap; - border-radius: 0.5rem; border-width: 1px; border-style: solid; - font-family: "Assistant"; + font-family: var(--font-family); user-select: none; - transition: all 150ms ease-out; - &--size-large { font-weight: 600; font-size: 0.875rem; min-height: 3rem; padding: 0.5rem 1.5rem; - gap: 0.75rem; letter-spacing: 0.4px; + + .ExcButton__contents { + gap: 0.75rem; + } } &--size-medium { @@ -169,9 +225,12 @@ font-size: 0.75rem; min-height: 2.5rem; padding: 0.5rem 1rem; - gap: 0.5rem; letter-spacing: normal; + + .ExcButton__contents { + gap: 0.5rem; + } } &--variant-icon { diff --git a/packages/excalidraw/components/FilledButton.tsx b/packages/excalidraw/components/FilledButton.tsx index 3f844cf37..ff17db623 100644 --- a/packages/excalidraw/components/FilledButton.tsx +++ b/packages/excalidraw/components/FilledButton.tsx @@ -1,7 +1,10 @@ -import React, { forwardRef } from "react"; +import React, { forwardRef, useState } from "react"; import clsx from "clsx"; import "./FilledButton.scss"; +import { AbortError } from "../errors"; +import Spinner from "./Spinner"; +import { isPromiseLike } from "../utils"; export type ButtonVariant = "filled" | "outlined" | "icon"; export type ButtonColor = "primary" | "danger" | "warning" | "muted"; @@ -11,7 +14,7 @@ export type FilledButtonProps = { label: string; children?: React.ReactNode; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; variant?: ButtonVariant; color?: ButtonColor; @@ -19,14 +22,14 @@ export type FilledButtonProps = { className?: string; fullWidth?: boolean; - startIcon?: React.ReactNode; + icon?: React.ReactNode; }; export const FilledButton = forwardRef( ( { children, - startIcon, + icon, onClick, label, variant = "filled", @@ -37,6 +40,27 @@ export const FilledButton = forwardRef( }, ref, ) => { + const [isLoading, setIsLoading] = useState(false); + + const _onClick = async (event: React.MouseEvent) => { + const ret = onClick?.(event); + + if (isPromiseLike(ret)) { + try { + setIsLoading(true); + await ret; + } catch (error: any) { + if (!(error instanceof AbortError)) { + throw error; + } else { + console.warn(error); + } + } finally { + setIsLoading(false); + } + } + }; + return ( ); }, diff --git a/packages/excalidraw/components/FixedSideContainer.scss b/packages/excalidraw/components/FixedSideContainer.scss index 62d77d505..87819ef68 100644 --- a/packages/excalidraw/components/FixedSideContainer.scss +++ b/packages/excalidraw/components/FixedSideContainer.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .FixedSideContainer { diff --git a/packages/excalidraw/components/FollowMode/FollowMode.scss b/packages/excalidraw/components/FollowMode/FollowMode.scss new file mode 100644 index 000000000..383b3ceed --- /dev/null +++ b/packages/excalidraw/components/FollowMode/FollowMode.scss @@ -0,0 +1,59 @@ +.excalidraw { + .follow-mode { + position: absolute; + box-sizing: border-box; + pointer-events: none; + border: 2px solid var(--color-primary-hover); + z-index: 9999; + display: flex; + align-items: flex-end; + justify-content: center; + + &__badge { + background-color: var(--color-primary-hover); + color: var(--color-primary-light); + padding: 0.25rem 0.5rem; + margin-bottom: 0.5rem; + border-radius: 0.5rem; + pointer-events: all; + font-size: 0.75rem; + display: flex; + gap: 0.5rem; + align-items: center; + + &__label { + display: flex; + white-space: pre-wrap; + line-height: 1; + } + + &__username { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; + } + } + + &__disconnect-btn { + all: unset; + cursor: pointer; + border-radius: 0.25rem; + + &:hover { + background-color: var(--color-primary-darker); + } + + &:active { + background-color: var(--color-primary-darkest); + } + + svg { + display: block; + width: 1rem; + height: 1rem; + } + } + } +} diff --git a/packages/excalidraw/components/FollowMode/FollowMode.tsx b/packages/excalidraw/components/FollowMode/FollowMode.tsx new file mode 100644 index 000000000..dc1746ca8 --- /dev/null +++ b/packages/excalidraw/components/FollowMode/FollowMode.tsx @@ -0,0 +1,38 @@ +import { UserToFollow } from "../../types"; +import { CloseIcon } from "../icons"; +import "./FollowMode.scss"; + +interface FollowModeProps { + width: number; + height: number; + userToFollow: UserToFollow; + onDisconnect: () => void; +} + +const FollowMode = ({ + height, + width, + userToFollow, + onDisconnect, +}: FollowModeProps) => { + return ( +
+
+
+ Following{" "} + + {userToFollow.username} + +
+ +
+
+ ); +}; + +export default FollowMode; diff --git a/packages/excalidraw/components/HelpDialog.scss b/packages/excalidraw/components/HelpDialog.scss index 0722c9723..7a3224beb 100644 --- a/packages/excalidraw/components/HelpDialog.scss +++ b/packages/excalidraw/components/HelpDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .HelpDialog { diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 961158c0c..23c9f8f47 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -4,9 +4,10 @@ import { KEYS } from "../keys"; import { Dialog } from "./Dialog"; import { getShortcutKey } from "../utils"; import "./HelpDialog.scss"; -import { ExternalLinkIcon } from "./icons"; +import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { probablySupportsClipboardBlob } from "../clipboard"; import { isDarwin, isFirefox, isWindows } from "../constants"; +import { getShortcutFromShortcutName } from "../actions/shortcuts"; const Header = () => (
@@ -16,8 +17,8 @@ const Header = () => ( target="_blank" rel="noopener noreferrer" > - {t("helpDialog.documentation")}
{ExternalLinkIcon}
+ {t("helpDialog.documentation")} ( target="_blank" rel="noopener noreferrer" > - {t("helpDialog.blog")}
{ExternalLinkIcon}
+ {t("helpDialog.blog")}
( target="_blank" rel="noopener noreferrer" > +
{GithubIcon}
{t("helpDialog.github")} -
{ExternalLinkIcon}
+
+ +
{youtubeIcon}
+ YouTube
); @@ -278,6 +288,17 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("stats.title")} shortcuts={[getShortcutKey("Alt+/")]} /> + { const hasSelection = isSomeElementSelected( elementsSnapshot, appStateSnapshot, ); - const appProps = useAppProps(); - const [projectName, setProjectName] = useState(appStateSnapshot.name); + const [projectName, setProjectName] = useState(name); const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection); const [exportWithBackground, setExportWithBackground] = useState( appStateSnapshot.exportBackground, @@ -124,9 +124,16 @@ const ImageExportModal = ({ setRenderError(null); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - return canvasToBlob(canvas).then(() => { - previewNode.replaceChildren(canvas); - }); + return canvasToBlob(canvas) + .then(() => { + previewNode.replaceChildren(canvas); + }) + .catch((e) => { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + }); }) .catch((error) => { console.error(error); @@ -158,10 +165,6 @@ const ImageExportModal = ({ className="TextInput" value={projectName} style={{ width: "30ch" }} - disabled={ - typeof appProps.name !== "undefined" || - appStateSnapshot.viewModeEnabled - } onChange={(event) => { setProjectName(event.target.value); actionManager.executeAction( @@ -271,7 +274,7 @@ const ImageExportModal = ({ exportingFrame, }) } - startIcon={downloadIcon} + icon={downloadIcon} > {t("imageExportDialog.button.exportToPng")} @@ -283,7 +286,7 @@ const ImageExportModal = ({ exportingFrame, }) } - startIcon={downloadIcon} + icon={downloadIcon} > {t("imageExportDialog.button.exportToSvg")} @@ -296,7 +299,7 @@ const ImageExportModal = ({ exportingFrame, }) } - startIcon={copyIcon} + icon={copyIcon} > {t("imageExportDialog.button.copyPngToClipboard")} @@ -347,6 +350,7 @@ export const ImageExportDialog = ({ actionManager, onExportImage, onCloseRequest, + name, }: { appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; @@ -354,6 +358,7 @@ export const ImageExportDialog = ({ actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; onCloseRequest: () => void; + name: string; }) => { // we need to take a snapshot so that the exported state can't be modified // while the dialog is open @@ -372,6 +377,7 @@ export const ImageExportDialog = ({ files={files} actionManager={actionManager} onExportImage={onExportImage} + name={name} /> ); diff --git a/packages/excalidraw/components/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index 7d967232d..75cc29d08 100644 --- a/packages/excalidraw/components/InlineIcon.tsx +++ b/packages/excalidraw/components/InlineIcon.tsx @@ -1,4 +1,4 @@ -export const InlineIcon = ({ icon }: { icon: JSX.Element }) => { +export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { return ( { try { trackEvent("export", "link", `ui (${getFrame()})`); - await onExportToBackend(elements, appState, files, canvas); + await onExportToBackend(elements, appState, files); onCloseRequest(); } catch (error: any) { setAppState({ errorMessage: error.message }); diff --git a/packages/excalidraw/components/LaserTool/LaserPointerButton.tsx b/packages/excalidraw/components/LaserPointerButton.tsx similarity index 87% rename from packages/excalidraw/components/LaserTool/LaserPointerButton.tsx rename to packages/excalidraw/components/LaserPointerButton.tsx index dbb843293..ae3cfb31a 100644 --- a/packages/excalidraw/components/LaserTool/LaserPointerButton.tsx +++ b/packages/excalidraw/components/LaserPointerButton.tsx @@ -1,8 +1,8 @@ -import "../ToolIcon.scss"; +import "./ToolIcon.scss"; import clsx from "clsx"; -import { ToolButtonSize } from "../ToolButton"; -import { laserPointerToolIcon } from "../icons"; +import { ToolButtonSize } from "./ToolButton"; +import { laserPointerToolIcon } from "./icons"; type LaserPointerIconProps = { title?: string; diff --git a/packages/excalidraw/components/LaserTool/LaserPathManager.ts b/packages/excalidraw/components/LaserTool/LaserPathManager.ts deleted file mode 100644 index 2f0c63955..000000000 --- a/packages/excalidraw/components/LaserTool/LaserPathManager.ts +++ /dev/null @@ -1,309 +0,0 @@ -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 = 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) { - 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; - } - } -} diff --git a/packages/excalidraw/components/LaserTool/LaserTool.tsx b/packages/excalidraw/components/LaserTool/LaserTool.tsx deleted file mode 100644 index e93d72dfc..000000000 --- a/packages/excalidraw/components/LaserTool/LaserTool.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useRef } from "react"; -import { LaserPathManager } from "./LaserPathManager"; -import "./LaserToolOverlay.scss"; - -type LaserToolOverlayProps = { - manager: LaserPathManager; -}; - -export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => { - const svgRef = useRef(null); - - useEffect(() => { - if (svgRef.current) { - manager.start(svgRef.current); - } - - return () => { - manager.stop(); - }; - }, [manager]); - - return ( -
- -
- ); -}; diff --git a/packages/excalidraw/components/LayerUI.scss b/packages/excalidraw/components/LayerUI.scss index 8898b0f83..36153d72b 100644 --- a/packages/excalidraw/components/LayerUI.scss +++ b/packages/excalidraw/components/LayerUI.scss @@ -1,5 +1,5 @@ @import "open-color/open-color"; -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .layer-ui__wrapper.animate { @@ -19,7 +19,14 @@ &__top-right { display: flex; + width: 100%; + justify-content: flex-end; gap: 0.75rem; + pointer-events: none !important; + + & > * { + pointer-events: var(--ui-pointerEvents); + } } &__footer { diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 71ed4f71c..eb8027138 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -60,7 +60,7 @@ import "./Toolbar.scss"; import { mutateElement } from "../element/mutateElement"; import { ShapeCache } from "../scene/ShapeCache"; import Scene from "../scene/Scene"; -import { LaserPointerButton } from "./LaserTool/LaserPointerButton"; +import { LaserPointerButton } from "./LaserPointerButton"; import { MagicSettings } from "./MagicSettings"; import { TTDDialog } from "./TTDDialog/TTDDialog"; @@ -195,6 +195,7 @@ const LayerUI = ({ actionManager={actionManager} onExportImage={onExportImage} onCloseRequest={() => setAppState({ openDialog: null })} + name={app.getName()} /> ); }; @@ -226,7 +227,7 @@ const LayerUI = ({ > @@ -338,7 +339,12 @@ const LayerUI = ({ }, )} > - + {appState.collaborators.size > 0 && ( + + )} {renderTopRightUI?.(device.editor.isMobile, appState)} {!appState.viewModeEnabled && // hide button when sidebar docked diff --git a/packages/excalidraw/components/LibraryUnit.scss b/packages/excalidraw/components/LibraryUnit.scss index 8a8ac5d3e..5ebe83f41 100644 --- a/packages/excalidraw/components/LibraryUnit.scss +++ b/packages/excalidraw/components/LibraryUnit.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .library-unit { diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index 91d0c518c..98f85a9ac 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -183,7 +183,7 @@ export const MobileMenu = ({
diff --git a/packages/excalidraw/components/Modal.scss b/packages/excalidraw/components/Modal.scss index 7dc62c113..1a355e2e1 100644 --- a/packages/excalidraw/components/Modal.scss +++ b/packages/excalidraw/components/Modal.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { &.excalidraw-modal-container { @@ -23,6 +23,20 @@ .Island { padding: 2.5rem; + border: 0; + box-shadow: none; + border-radius: 0; + } + + &.animations-disabled { + .Modal__background { + animation: none; + } + + .Modal__content { + animation: none; + opacity: 1; + } } } @@ -35,7 +49,7 @@ z-index: 1; background-color: rgba(#121212, 0.2); - animation: Modal__background__fade-in 0.125s linear forwards; + animation: Modal__background__fade-in 0.1s linear forwards; } .Modal__content { @@ -47,7 +61,8 @@ opacity: 0; transform: translateY(10px); - animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards; + animation: Modal__content_fade-in 0.025s ease-out 0s forwards; + position: relative; overflow-y: auto; @@ -56,7 +71,7 @@ border: 1px solid var(--dialog-border-color); box-shadow: var(--modal-shadow); - border-radius: 6px; + border-radius: 0.75rem; box-sizing: border-box; &:focus { diff --git a/packages/excalidraw/components/Modal.tsx b/packages/excalidraw/components/Modal.tsx index e02583ef0..d27f41a22 100644 --- a/packages/excalidraw/components/Modal.tsx +++ b/packages/excalidraw/components/Modal.tsx @@ -5,6 +5,7 @@ import clsx from "clsx"; import { KEYS } from "../keys"; import { AppState } from "../types"; import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer"; +import { useRef } from "react"; export const Modal: React.FC<{ className?: string; @@ -20,6 +21,10 @@ export const Modal: React.FC<{ className: "excalidraw-modal-container", }); + const animationsDisabledRef = useRef( + document.body.classList.contains("excalidraw-animations-disabled"), + ); + if (!modalRoot) { return null; } @@ -34,7 +39,9 @@ export const Modal: React.FC<{ return createPortal(
void; label: string; - isNameEditable: boolean; ignoreFocus?: boolean; }; @@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => { return (
- {props.isNameEditable ? ( - setFileName(event.target.value)} - /> - ) : ( - - {props.value} - - )} + setFileName(event.target.value)} + />
); }; diff --git a/packages/excalidraw/components/PublishLibrary.scss b/packages/excalidraw/components/PublishLibrary.scss index fd7db0fe4..2c166900e 100644 --- a/packages/excalidraw/components/PublishLibrary.scss +++ b/packages/excalidraw/components/PublishLibrary.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .publish-library { diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index c14d42d50..51e14febc 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -6,7 +6,7 @@ import { t } from "../i18n"; import Trans from "./Trans"; import { LibraryItems, LibraryItem, UIAppState } from "../types"; -import { exportToCanvas, exportToSvg } from "../../utils"; +import { exportToCanvas, exportToSvg } from "../../utils/export"; import { EDITOR_LS_KEYS, EXPORT_DATA_TYPES, diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index eb70f4a82..76ee20a16 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { --RadioGroup-background: var(--island-bg-color); diff --git a/packages/excalidraw/components/RadioGroup.tsx b/packages/excalidraw/components/RadioGroup.tsx index 40c6551f1..64d4d587d 100644 --- a/packages/excalidraw/components/RadioGroup.tsx +++ b/packages/excalidraw/components/RadioGroup.tsx @@ -3,7 +3,8 @@ import "./RadioGroup.scss"; export type RadioGroupChoice = { value: T; - label: string; + label: React.ReactNode; + ariaLabel?: string; }; export type RadioGroupProps = { @@ -26,13 +27,15 @@ export const RadioGroup = function ({ className={clsx("RadioGroup__choice", { active: choice.value === value, })} - key={choice.label} + key={String(choice.value)} + title={choice.ariaLabel} > onChange(choice.value)} + aria-label={choice.ariaLabel} /> {choice.label}
diff --git a/packages/excalidraw/components/LaserTool/LaserToolOverlay.scss b/packages/excalidraw/components/SVGLayer.scss similarity index 80% rename from packages/excalidraw/components/LaserTool/LaserToolOverlay.scss rename to packages/excalidraw/components/SVGLayer.scss index da874b452..5eb0353aa 100644 --- a/packages/excalidraw/components/LaserTool/LaserToolOverlay.scss +++ b/packages/excalidraw/components/SVGLayer.scss @@ -1,5 +1,5 @@ .excalidraw { - .LaserToolOverlay { + .SVGLayer { pointer-events: none; width: 100vw; height: 100vh; @@ -9,10 +9,12 @@ z-index: 2; - .LaserToolOverlayCanvas { + & svg { image-rendering: auto; overflow: visible; position: absolute; + width: 100%; + height: 100%; top: 0; left: 0; } diff --git a/packages/excalidraw/components/SVGLayer.tsx b/packages/excalidraw/components/SVGLayer.tsx new file mode 100644 index 000000000..feaebaf94 --- /dev/null +++ b/packages/excalidraw/components/SVGLayer.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; +import { Trail } from "../animated-trail"; + +import "./SVGLayer.scss"; + +type SVGLayerProps = { + trails: Trail[]; +}; + +export const SVGLayer = ({ trails }: SVGLayerProps) => { + const svgRef = useRef(null); + + useEffect(() => { + if (svgRef.current) { + for (const trail of trails) { + trail.start(svgRef.current); + } + } + + return () => { + for (const trail of trails) { + trail.stop(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, trails); + + return ( +
+ +
+ ); +}; diff --git a/packages/excalidraw/components/ShareableLinkDialog.scss b/packages/excalidraw/components/ShareableLinkDialog.scss index 595acf7dd..2429d50ca 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.scss +++ b/packages/excalidraw/components/ShareableLinkDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module"; +@import "../css/variables.module.scss"; .excalidraw { .ShareableLinkDialog { @@ -22,7 +22,7 @@ } &__popover { - @keyframes RoomDialog__popover__scaleIn { + @keyframes ShareableLinkDialog__popover__scaleIn { from { opacity: 0; } @@ -61,7 +61,7 @@ } transform-origin: var(--radix-popover-content-transform-origin); - animation: RoomDialog__popover__scaleIn 150ms ease-out; + animation: ShareableLinkDialog__popover__scaleIn 150ms ease-out; } &__linkRow { diff --git a/packages/excalidraw/components/ShareableLinkDialog.tsx b/packages/excalidraw/components/ShareableLinkDialog.tsx index 7a53a4a82..145cc21b5 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.tsx +++ b/packages/excalidraw/components/ShareableLinkDialog.tsx @@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(link); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; @@ -66,7 +65,7 @@ export const ShareableLinkDialog = ({ diff --git a/packages/excalidraw/components/Sidebar/Sidebar.scss b/packages/excalidraw/components/Sidebar/Sidebar.scss index abb6f0095..2571d128f 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.scss +++ b/packages/excalidraw/components/Sidebar/Sidebar.scss @@ -1,5 +1,5 @@ @import "open-color/open-color"; -@import "../../css/variables.module"; +@import "../../css/variables.module.scss"; .excalidraw { .sidebar { diff --git a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx index 9787f9a73..6b60418b5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx @@ -85,7 +85,7 @@ describe("Sidebar", () => { }); }); - it("should toggle sidebar using props.toggleMenu()", async () => { + it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => { const { container } = await render( @@ -158,6 +158,20 @@ describe("Sidebar", () => { const sidebars = container.querySelectorAll(".sidebar"); expect(sidebars.length).toBe(1); }); + + // closing sidebar using `{ name: null }` + // ------------------------------------------------------------------------- + expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + expect(window.h.app.toggleSidebar({ name: null })).toBe(false); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); }); }); @@ -329,4 +343,70 @@ describe("Sidebar", () => { ); }); }); + + describe("Sidebar.tab", () => { + it("should toggle sidebars tabs correctly", async () => { + const { container } = await render( + + + + Library + Comments + + + , + ); + + await withExcalidrawDimensions( + { width: 1920, height: 1080 }, + async () => { + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).toBeNull(); + + // open library sidebar + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "library" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).not.toBeNull(); + + // switch to comments tab + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + + // toggle sidebar closed + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(false); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).toBeNull(); + + // toggle sidebar open + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + }, + ); + }); + }); }); diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 741a69fd1..f7eacc1b1 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -10,7 +10,7 @@ export const SidebarTab = ({ children: React.ReactNode; } & React.HTMLAttributes) => { return ( - + {children} ); diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss index 834df6563..5b003cdc5 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss @@ -1,4 +1,4 @@ -@import "../../css/variables.module"; +@import "../../css/variables.module.scss"; .excalidraw { .sidebar-trigger { @@ -21,10 +21,15 @@ width: var(--lg-icon-size); height: var(--lg-icon-size); } + + &__label-element { + align-self: flex-start; + } } .default-sidebar-trigger .sidebar-trigger__label { display: block; + white-space: nowrap; } &.excalidraw--mobile .default-sidebar-trigger .sidebar-trigger__label { diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 711432818..889156eba 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -19,7 +19,7 @@ export const SidebarTrigger = ({ const appState = useUIAppState(); return ( -
)} diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 5594f356e..010d2ea81 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -2,7 +2,6 @@ import cssVariables from "./css/variables.module.scss"; import { AppProps } from "./types"; import { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { COLOR_PALETTE } from "./colors"; - export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); @@ -13,10 +12,17 @@ export const isFirefox = export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; export const isSafari = !isChrome && navigator.userAgent.indexOf("Safari") !== -1; +export const isIOS = + /iPad|iPhone/.test(navigator.platform) || + // iPadOS 13+ + (navigator.userAgent.includes("Mac") && "ontouchend" in document); // keeping function so it can be mocked in test export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; +export const supportsResizeObserver = + typeof window !== "undefined" && "ResizeObserver" in window; + export const APP_NAME = "Excalidraw"; export const DRAGGING_THRESHOLD = 10; // px @@ -25,6 +31,7 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; export const SHIFT_LOCKING_ANGLE = Math.PI / 12; +export const DEFAULT_LASER_COLOR = "red"; export const CURSOR_TYPE = { TEXT: "text", CROSSHAIR: "crosshair", @@ -39,6 +46,7 @@ export const POINTER_BUTTON = { WHEEL: 1, SECONDARY: 2, TOUCH: -1, + ERASER: 5, } as const; export const POINTER_EVENTS = { @@ -138,6 +146,12 @@ export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; +export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; + +export const COLOR_WHITE = "#ffffff"; +export const COLOR_CHARCOAL_BLACK = "#1e1e1e"; +// keep this in sync with CSS +export const COLOR_VOICE_CALL = "#a2f1a6"; export const CANVAS_ONLY_ACTIONS = ["selectAll"]; @@ -302,10 +316,6 @@ export const ROUNDNESS = { ADAPTIVE_RADIUS: 3, } as const; -/** key containt id of precedeing elemnt id we use in reconciliation during - * collaboration */ -export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; - export const ROUGHNESS = { architect: 0, artist: 1, @@ -376,3 +386,9 @@ export const EDITOR_LS_KEYS = { MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw", PUBLISH_LIBRARY: "publish-library-data", } as const; + +/** + * not translated as this is used only in public, stateless API as default value + * where filename is optional and we can't retrieve name from app state + */ +export const DEFAULT_FILENAME = "Untitled"; diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index cafd21fbe..87168520e 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -1,4 +1,4 @@ -@import "./variables.module"; +@import "./variables.module.scss"; @import "./theme"; :root { diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index 4fb8bf81f..4bcf71754 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -84,6 +84,7 @@ --color-primary-darkest: #4a47b1; --color-primary-light: #e3e2fe; --color-primary-light-darker: #d7d5ff; + --color-primary-hover: #5753d0; --color-gray-10: #f5f5f5; --color-gray-20: #ebebeb; @@ -205,6 +206,7 @@ --color-primary-darkest: #beb9ff; --color-primary-light: #4f4d6f; --color-primary-light-darker: #43415e; + --color-primary-hover: #bbb8ff; --color-text-warning: var(--color-gray-80); diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 634752dfa..71097ba3e 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -68,6 +68,7 @@ cursor: pointer; background-color: var(--button-bg, var(--island-bg-color)); color: var(--button-color, var(--color-on-surface)); + font-family: var(--ui-font); svg { width: var(--button-width, var(--lg-icon-size)); @@ -114,6 +115,51 @@ } } +@mixin avatarStyles { + width: var(--avatar-size, 1.5rem); + height: var(--avatar-size, 1.5rem); + position: relative; + border-radius: 100%; + outline-offset: 2px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + font-size: 0.75rem; + font-weight: 800; + line-height: 1; + color: var(--color-gray-90); + flex: 0 0 auto; + + &:active { + transform: scale(0.94); + } + + &-img { + width: 100%; + height: 100%; + border-radius: 100%; + } + + &::before { + content: ""; + position: absolute; + top: -3px; + right: -3px; + bottom: -3px; + left: -3px; + border-radius: 100%; + } + + &.is-followed::before { + border-color: var(--color-primary-hover); + box-shadow: 0 0 0 1px var(--color-primary-hover); + } + &.is-current-user { + cursor: auto; + } +} + @mixin filledButtonOnCanvas { border: none; box-shadow: 0 0 0 1px var(--color-surface-lowest); diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index dcd48f8b5..adb5b0372 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -14,11 +14,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 300, "id": Any, + "index": "a0", "isDeleted": false, "link": null, "locked": false, @@ -31,7 +33,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "strokeWidth": 2, "type": "ellipse", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 300, "x": 630, @@ -49,11 +51,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a1", "isDeleted": false, "link": null, "locked": false, @@ -66,7 +70,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "strokeWidth": 2, "type": "diamond", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 140, "x": 96, @@ -79,6 +83,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", @@ -90,6 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "groupIds": [], "height": 35, "id": Any, + "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -119,7 +125,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 395, "x": 247, @@ -132,6 +138,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", @@ -143,6 +150,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "groupIds": [], "height": 0, "id": Any, + "index": "a3", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -172,7 +180,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 400, "x": 227, @@ -190,11 +198,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 300, "id": Any, + "index": "a4", "isDeleted": false, "link": null, "locked": false, @@ -207,7 +217,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 300, "x": -53, @@ -219,7 +229,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id48", @@ -227,6 +236,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -234,6 +244,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "groupIds": [], "height": 25, "id": Any, + "index": "a0", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -250,7 +261,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "textAlign": "left", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "top", "width": 70, @@ -263,7 +274,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id48", @@ -271,6 +281,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -278,6 +289,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "groupIds": [], "height": 25, "id": Any, + "index": "a1", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -294,7 +306,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "textAlign": "left", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "top", "width": 100, @@ -313,6 +325,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", @@ -324,6 +337,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "groupIds": [], "height": 0, "id": Any, + "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -353,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 100, "x": 255, @@ -365,9 +379,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id48", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -375,6 +389,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "groupIds": [], "height": 25, "id": Any, + "index": "a3", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -391,7 +406,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 130, @@ -410,6 +425,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "id40", @@ -421,6 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "groupIds": [], "height": 0, "id": Any, + "index": "a0", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -450,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 100, "x": 255, @@ -462,9 +479,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id37", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -472,6 +489,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "groupIds": [], "height": 25, "id": Any, + "index": "a1", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -488,7 +506,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 130, @@ -507,11 +525,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a2", "isDeleted": false, "link": null, "locked": false, @@ -524,7 +544,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 100, "x": 155, @@ -542,11 +562,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a3", "isDeleted": false, "link": null, "locked": false, @@ -559,7 +581,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "strokeWidth": 2, "type": "ellipse", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 100, "x": 355, @@ -577,6 +599,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "id44", @@ -588,6 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "groupIds": [], "height": 0, "id": Any, + "index": "a0", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -617,7 +641,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 100, "x": 255, @@ -629,9 +653,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id41", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -639,6 +663,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "groupIds": [], "height": 25, "id": Any, + "index": "a1", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -655,7 +680,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 130, @@ -668,7 +693,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id41", @@ -676,6 +700,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -683,6 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "groupIds": [], "height": 25, "id": Any, + "index": "a2", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -699,7 +725,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "textAlign": "left", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "top", "width": 70, @@ -712,7 +738,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id41", @@ -720,6 +745,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -727,6 +753,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "groupIds": [], "height": 25, "id": Any, + "index": "a3", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -743,7 +770,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "textAlign": "left", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "top", "width": 100, @@ -757,11 +784,13 @@ exports[`Test Transform > should not allow duplicate ids 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 200, "id": "rect-1", + "index": "a0", "isDeleted": false, "link": null, "locked": false, @@ -774,7 +803,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = ` "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 300, @@ -787,6 +816,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -794,6 +824,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "groupIds": [], "height": 0, "id": Any, + "index": "a0", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -819,7 +850,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -832,6 +863,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "triangle", "endBinding": null, "fillStyle": "solid", @@ -839,6 +871,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "groupIds": [], "height": 0, "id": Any, + "index": "a1", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -864,7 +897,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 450, @@ -877,6 +910,7 @@ exports[`Test Transform > should transform linear elements 3`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -884,6 +918,7 @@ exports[`Test Transform > should transform linear elements 3`] = ` "groupIds": [], "height": 0, "id": Any, + "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -909,7 +944,7 @@ exports[`Test Transform > should transform linear elements 3`] = ` "strokeWidth": 2, "type": "line", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -922,6 +957,7 @@ exports[`Test Transform > should transform linear elements 4`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -929,6 +965,7 @@ exports[`Test Transform > should transform linear elements 4`] = ` "groupIds": [], "height": 0, "id": Any, + "index": "a3", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -954,7 +991,7 @@ exports[`Test Transform > should transform linear elements 4`] = ` "strokeWidth": 2, "type": "line", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 450, @@ -967,11 +1004,13 @@ exports[`Test Transform > should transform regular shapes 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a0", "isDeleted": false, "link": null, "locked": false, @@ -984,7 +1023,7 @@ exports[`Test Transform > should transform regular shapes 1`] = ` "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -997,11 +1036,13 @@ exports[`Test Transform > should transform regular shapes 2`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a1", "isDeleted": false, "link": null, "locked": false, @@ -1014,7 +1055,7 @@ exports[`Test Transform > should transform regular shapes 2`] = ` "strokeWidth": 2, "type": "ellipse", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -1027,11 +1068,13 @@ exports[`Test Transform > should transform regular shapes 3`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a2", "isDeleted": false, "link": null, "locked": false, @@ -1044,7 +1087,7 @@ exports[`Test Transform > should transform regular shapes 3`] = ` "strokeWidth": 2, "type": "diamond", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -1057,11 +1100,13 @@ exports[`Test Transform > should transform regular shapes 4`] = ` "angle": 0, "backgroundColor": "#c0eb75", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a3", "isDeleted": false, "link": null, "locked": false, @@ -1074,7 +1119,7 @@ exports[`Test Transform > should transform regular shapes 4`] = ` "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 200, "x": 300, @@ -1087,11 +1132,13 @@ exports[`Test Transform > should transform regular shapes 5`] = ` "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a4", "isDeleted": false, "link": null, "locked": false, @@ -1104,7 +1151,7 @@ exports[`Test Transform > should transform regular shapes 5`] = ` "strokeWidth": 2, "type": "ellipse", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 200, "x": 300, @@ -1117,11 +1164,13 @@ exports[`Test Transform > should transform regular shapes 6`] = ` "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], "height": 100, "id": Any, + "index": "a5", "isDeleted": false, "link": null, "locked": false, @@ -1134,7 +1183,7 @@ exports[`Test Transform > should transform regular shapes 6`] = ` "strokeWidth": 2, "type": "diamond", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 200, "x": 300, @@ -1146,9 +1195,9 @@ exports[`Test Transform > should transform text element 1`] = ` { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1156,6 +1205,7 @@ exports[`Test Transform > should transform text element 1`] = ` "groupIds": [], "height": 25, "id": Any, + "index": "a0", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1172,7 +1222,7 @@ exports[`Test Transform > should transform text element 1`] = ` "textAlign": "left", "type": "text", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "verticalAlign": "top", "width": 120, @@ -1185,9 +1235,9 @@ exports[`Test Transform > should transform text element 2`] = ` { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1195,6 +1245,7 @@ exports[`Test Transform > should transform text element 2`] = ` "groupIds": [], "height": 25, "id": Any, + "index": "a1", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1211,7 +1262,7 @@ exports[`Test Transform > should transform text element 2`] = ` "textAlign": "left", "type": "text", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "verticalAlign": "top", "width": 190, @@ -1220,6 +1271,546 @@ exports[`Test Transform > should transform text element 2`] = ` } `; +exports[`Test Transform > should transform the elements correctly when linear elements have single point 1`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id54", + "type": "text", + }, + { + "id": "Bob_B", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 163, + "id": Any, + "index": "a0", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any, + "width": 166.03125, + "x": 0, + "y": 0, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 2`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id55", + "type": "text", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 114, + "id": Any, + "index": "a1", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 2, + "versionNonce": Any, + "width": 120.265625, + "x": 364.546875, + "y": 0, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 3`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id56", + "type": "text", + }, + { + "id": "Bob_Alice", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 44, + "id": Any, + "index": "a2", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 3, + "versionNonce": Any, + "width": 70.265625, + "x": 389.546875, + "y": 35, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 4`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id57", + "type": "text", + }, + { + "id": "Bob_Alice", + "type": "arrow", + }, + { + "id": "Bob_B", + "type": "arrow", + }, + ], + "customData": undefined, + "fillStyle": "solid", + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 44, + "id": Any, + "index": "a3", + "isDeleted": false, + "link": null, + "locked": false, + "opacity": 100, + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "rectangle", + "updated": 1, + "version": 4, + "versionNonce": Any, + "width": 56.4921875, + "x": 54.76953125, + "y": 35, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 5`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id58", + "type": "text", + }, + ], + "customData": undefined, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "Alice", + "focus": 0, + "gap": 5.299874999999986, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any, + "index": "a4", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0.5, + 0, + ], + [ + 272.485, + 0, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "seed": Any, + "startArrowhead": null, + "startBinding": { + "elementId": "Bob", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any, + "width": 272.985, + "x": 111.262, + "y": 57, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 6`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": [ + { + "id": "id59", + "type": "text", + }, + ], + "customData": undefined, + "endArrowhead": "arrow", + "endBinding": { + "elementId": "B", + "focus": 0, + "gap": 1, + }, + "fillStyle": "solid", + "frameId": null, + "groupIds": [], + "height": 0, + "id": Any, + "index": "a5", + "isDeleted": false, + "lastCommittedPoint": null, + "link": null, + "locked": false, + "opacity": 100, + "points": [ + [ + 0, + 0, + ], + ], + "roughness": 1, + "roundness": { + "type": 2, + }, + "seed": Any, + "startArrowhead": null, + "startBinding": { + "elementId": "Bob", + "focus": 0, + "gap": 1, + }, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "type": "arrow", + "updated": 1, + "version": 4, + "versionNonce": Any, + "width": 0, + "x": 77.017, + "y": 79, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "B", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 1, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 25, + "id": Any, + "index": "a6", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "B", + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "B", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any, + "verticalAlign": "top", + "width": 10, + "x": 78.015625, + "y": 5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "A", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 1, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 25, + "id": Any, + "index": "a7", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "A", + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "A", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any, + "verticalAlign": "top", + "width": 10, + "x": 419.6796875, + "y": 5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Alice", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 1, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_A", + ], + "height": 25, + "id": Any, + "index": "a8", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Alice", + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Alice", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any, + "verticalAlign": "middle", + "width": 50, + "x": 399.6796875, + "y": 44.5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Bob", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 1, + "fontSize": 20, + "frameId": null, + "groupIds": [ + "subgraph_group_B", + ], + "height": 25, + "id": Any, + "index": "a9", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Bob", + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Bob", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any, + "verticalAlign": "middle", + "width": 30, + "x": 68.015625, + "y": 44.5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Bob_Alice", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 1, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any, + "index": "aA", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "How are you?", + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "How are you?", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any, + "verticalAlign": "middle", + "width": 120, + "x": 187.7545, + "y": 44.5, +} +`; + +exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = ` +{ + "angle": 0, + "backgroundColor": "transparent", + "boundElements": null, + "containerId": "Bob_B", + "customData": undefined, + "fillStyle": "solid", + "fontFamily": 1, + "fontSize": 20, + "frameId": null, + "groupIds": [], + "height": 25, + "id": Any, + "index": "aB", + "isDeleted": false, + "lineHeight": 1.25, + "link": null, + "locked": false, + "opacity": 100, + "originalText": "Friendship", + "roughness": 1, + "roundness": null, + "seed": Any, + "strokeColor": "#1e1e1e", + "strokeStyle": "solid", + "strokeWidth": 2, + "text": "Friendship", + "textAlign": "center", + "type": "text", + "updated": 1, + "version": 3, + "versionNonce": Any, + "verticalAlign": "middle", + "width": 100, + "x": 27.016999999999996, + "y": 66.5, +} +`; + exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = ` { "angle": 0, @@ -1230,6 +1821,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1237,6 +1829,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 0, "id": Any, + "index": "a0", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -1262,7 +1855,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -1280,6 +1873,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1287,6 +1881,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 0, "id": Any, + "index": "a1", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -1312,7 +1907,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -1330,6 +1925,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1337,6 +1933,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 0, "id": Any, + "index": "a2", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -1362,7 +1959,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -1380,6 +1977,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1387,6 +1985,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 0, "id": Any, + "index": "a3", "isDeleted": false, "lastCommittedPoint": null, "link": null, @@ -1412,7 +2011,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 1, + "version": 2, "versionNonce": Any, "width": 100, "x": 100, @@ -1424,9 +2023,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id25", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1434,6 +2033,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 25, "id": Any, + "index": "a4", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1450,7 +2050,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 130, @@ -1463,9 +2063,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id26", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1473,6 +2073,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 25, "id": Any, + "index": "a5", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1489,7 +2090,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 200, @@ -1502,9 +2103,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id27", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1512,6 +2113,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 50, "id": Any, + "index": "a6", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1529,7 +2131,7 @@ LABELLED ARROW", "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 150, @@ -1542,9 +2144,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id28", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1552,6 +2154,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "groupIds": [], "height": 50, "id": Any, + "index": "a7", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1569,7 +2172,7 @@ LABELLED ARROW", "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 150, @@ -1588,11 +2191,13 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 35, "id": Any, + "index": "a0", "isDeleted": false, "link": null, "locked": false, @@ -1605,7 +2210,7 @@ exports[`Test Transform > should transform to text containers when label provide "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 3, + "version": 4, "versionNonce": Any, "width": 250, "x": 100, @@ -1623,11 +2228,13 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 85, "id": Any, + "index": "a1", "isDeleted": false, "link": null, "locked": false, @@ -1640,7 +2247,7 @@ exports[`Test Transform > should transform to text containers when label provide "strokeWidth": 2, "type": "ellipse", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 200, "x": 500, @@ -1658,11 +2265,13 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 170, "id": Any, + "index": "a2", "isDeleted": false, "link": null, "locked": false, @@ -1675,7 +2284,7 @@ exports[`Test Transform > should transform to text containers when label provide "strokeWidth": 2, "type": "diamond", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 280, "x": 100, @@ -1693,11 +2302,13 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 120, "id": Any, + "index": "a3", "isDeleted": false, "link": null, "locked": false, @@ -1710,7 +2321,7 @@ exports[`Test Transform > should transform to text containers when label provide "strokeWidth": 2, "type": "diamond", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 300, "x": 100, @@ -1728,11 +2339,13 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 85, "id": Any, + "index": "a4", "isDeleted": false, "link": null, "locked": false, @@ -1745,7 +2358,7 @@ exports[`Test Transform > should transform to text containers when label provide "strokeWidth": 2, "type": "rectangle", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 200, "x": 500, @@ -1763,11 +2376,13 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], "height": 120, "id": Any, + "index": "a5", "isDeleted": false, "link": null, "locked": false, @@ -1780,7 +2395,7 @@ exports[`Test Transform > should transform to text containers when label provide "strokeWidth": 2, "type": "ellipse", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "width": 200, "x": 500, @@ -1792,9 +2407,9 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id13", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1802,6 +2417,7 @@ exports[`Test Transform > should transform to text containers when label provide "groupIds": [], "height": 25, "id": Any, + "index": "a6", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1818,7 +2434,7 @@ exports[`Test Transform > should transform to text containers when label provide "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 240, @@ -1831,9 +2447,9 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id14", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1841,6 +2457,7 @@ exports[`Test Transform > should transform to text containers when label provide "groupIds": [], "height": 50, "id": Any, + "index": "a7", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1858,7 +2475,7 @@ CONTAINER", "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 130, @@ -1871,9 +2488,9 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id15", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1881,6 +2498,7 @@ exports[`Test Transform > should transform to text containers when label provide "groupIds": [], "height": 75, "id": Any, + "index": "a8", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1900,7 +2518,7 @@ CONTAINER", "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 90, @@ -1913,9 +2531,9 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id16", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1923,6 +2541,7 @@ exports[`Test Transform > should transform to text containers when label provide "groupIds": [], "height": 50, "id": Any, + "index": "a9", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1940,7 +2559,7 @@ TEXT CONTAINER", "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 140, @@ -1953,9 +2572,9 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id17", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1963,6 +2582,7 @@ exports[`Test Transform > should transform to text containers when label provide "groupIds": [], "height": 75, "id": Any, + "index": "aA", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -1981,7 +2601,7 @@ CONTAINER", "textAlign": "left", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "top", "width": 170, @@ -1994,9 +2614,9 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id18", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -2004,6 +2624,7 @@ exports[`Test Transform > should transform to text containers when label provide "groupIds": [], "height": 75, "id": Any, + "index": "aB", "isDeleted": false, "lineHeight": 1.25, "link": null, @@ -2022,7 +2643,7 @@ CONTAINER", "textAlign": "center", "type": "text", "updated": 1, - "version": 2, + "version": 3, "versionNonce": Any, "verticalAlign": "middle", "width": 130, diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index b1b625700..527f1c0ea 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -4,7 +4,6 @@ import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; import { ExcalidrawElement, FileId } from "../element/types"; import { CanvasError, ImageSceneDataError } from "../errors"; -import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { AppState, DataURL, LibraryItem } from "../types"; import { ValueOf } from "../utility-types"; @@ -19,17 +18,15 @@ const parseFileContents = async (blob: Blob | File) => { if (blob.type === MIME_TYPES.png) { try { - return await ( - await import(/* webpackChunkName: "image" */ "./image") - ).decodePngMetadata(blob); + return await (await import("./image")).decodePngMetadata(blob); } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); + throw new ImageSceneDataError("Error: cannot restore image"); } } } else { @@ -49,18 +46,18 @@ const parseFileContents = async (blob: Blob | File) => { if (blob.type === MIME_TYPES.svg) { try { return await ( - await import(/* webpackChunkName: "image" */ "./image") + await import("./image") ).decodeSvgMetadata({ svg: contents, }); } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); + throw new ImageSceneDataError("Error: cannot restore image"); } } } @@ -132,7 +129,7 @@ export const loadSceneOrLibraryFromBlob = async ( } catch (error: any) { if (isSupportedImageFile(blob)) { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } @@ -165,12 +162,12 @@ export const loadSceneOrLibraryFromBlob = async ( data, }; } - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } catch (error: any) { if (error instanceof ImageSceneDataError) { throw error; } - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } }; @@ -189,7 +186,7 @@ export const loadFromBlob = async ( fileHandle, ); if (ret.type !== MIME_TYPES.excalidraw) { - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } return ret.data; }; @@ -224,10 +221,7 @@ export const canvasToBlob = async ( canvas.toBlob((blob) => { if (!blob) { return reject( - new CanvasError( - t("canvasError.canvasTooBig"), - "CANVAS_POSSIBLY_TOO_BIG", - ), + new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"), ); } resolve(blob); @@ -316,7 +310,7 @@ export const resizeImageFile = async ( } if (!isSupportedImageFile(file)) { - throw new Error(t("errors.unsupportedFileType")); + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); } return new File( @@ -342,11 +336,11 @@ export const ImageURLToFile = async ( try { response = await fetch(imageUrl); } catch (error: any) { - throw new Error(t("errors.failedToFetchImage")); + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } if (!response.ok) { - throw new Error(t("errors.failedToFetchImage")); + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } const blob = await response.blob(); @@ -356,7 +350,7 @@ export const ImageURLToFile = async ( return new File([blob], name, { type: blob.type }); } - throw new Error(t("errors.unsupportedFileType")); + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); }; export const getFileFromEvent = async ( diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index fa29604f4..11f64d23e 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -76,7 +76,7 @@ export const fileOpen = (opts: { }; export const fileSave = ( - blob: Blob, + blob: Blob | Promise, opts: { /** supply without the extension */ name: string; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index cb7bed208..3d0555e10 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -2,7 +2,12 @@ import { copyBlobToClipboardAsPng, copyTextToSystemClipboard, } from "../clipboard"; -import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants"; +import { + DEFAULT_EXPORT_PADDING, + DEFAULT_FILENAME, + isFirefox, + MIME_TYPES, +} from "../constants"; import { getNonDeletedElements } from "../element"; import { isFrameLikeElement } from "../element/typeChecks"; import { @@ -11,7 +16,6 @@ import { NonDeletedExcalidrawElement, } from "../element/types"; import { t } from "../i18n"; -import { elementsOverlappingBBox } from "../../withinBounds"; import { isSomeElementSelected, getSelectedElements } from "../scene"; import { exportToCanvas, exportToSvg } from "../scene/export"; import { ExportType } from "../scene/types"; @@ -20,6 +24,7 @@ import { cloneJSON } from "../utils"; import { canvasToBlob } from "./blob"; import { fileSave, FileSystemHandle } from "./filesystem"; import { serializeAsJSON } from "./json"; +import { getElementsOverlappingFrame } from "../frame"; export { loadFromBlob } from "./blob"; export { loadFromJSON, saveAsJSON } from "./json"; @@ -56,11 +61,7 @@ export const prepareElementsForExport = ( isFrameLikeElement(exportedElements[0]) ) { exportingFrame = exportedElements[0]; - exportedElements = elementsOverlappingBBox({ - elements, - bounds: exportingFrame, - type: "overlap", - }); + exportedElements = getElementsOverlappingFrame(elements, exportingFrame); } else if (exportedElements.length > 1) { exportedElements = getSelectedElements( elements, @@ -88,14 +89,15 @@ export const exportCanvas = async ( exportBackground, exportPadding = DEFAULT_EXPORT_PADDING, viewBackgroundColor, - name, + name = appState.name || DEFAULT_FILENAME, fileHandle = null, exportingFrame = null, }: { exportBackground: boolean; exportPadding?: number; viewBackgroundColor: string; - name: string; + /** filename, if applicable */ + name?: string; fileHandle?: FileSystemHandle | null; exportingFrame: ExcalidrawFrameLikeElement | null; }, @@ -104,7 +106,7 @@ export const exportCanvas = async ( throw new Error(t("alerts.cannotExportEmptyCanvas")); } if (type === "svg" || type === "clipboard-svg") { - const tempSvg = await exportToSvg( + const svgPromise = exportToSvg( elements, { exportBackground, @@ -117,9 +119,12 @@ export const exportCanvas = async ( files, { exportingFrame }, ); + if (type === "svg") { - return await fileSave( - new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }), + return fileSave( + svgPromise.then((svg) => { + return new Blob([svg.outerHTML], { type: MIME_TYPES.svg }); + }), { description: "Export to SVG", name, @@ -128,7 +133,12 @@ export const exportCanvas = async ( }, ); } else if (type === "clipboard-svg") { - await copyTextToSystemClipboard(tempSvg.outerHTML); + const svg = await svgPromise.then((svg) => svg.outerHTML); + try { + await copyTextToSystemClipboard(svg); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return; } } @@ -141,17 +151,20 @@ export const exportCanvas = async ( }); if (type === "png") { - let blob = await canvasToBlob(tempCanvas); + let blob = canvasToBlob(tempCanvas); + if (appState.exportEmbedScene) { - blob = await ( - await import(/* webpackChunkName: "image" */ "./image") - ).encodePngMetadata({ - blob, - metadata: serializeAsJSON(elements, appState, files, "local"), - }); + blob = blob.then((blob) => + import("./image").then(({ encodePngMetadata }) => + encodePngMetadata({ + blob, + metadata: serializeAsJSON(elements, appState, files, "local"), + }), + ), + ); } - return await fileSave(blob, { + return fileSave(blob, { description: "Export to PNG", name, // FIXME reintroduce `excalidraw.png` when most people upgrade away @@ -166,7 +179,7 @@ export const exportCanvas = async ( } catch (error: any) { console.warn(error); if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { - throw error; + throw new Error(t("canvasError.canvasTooBig")); } // TypeError *probably* suggests ClipboardItem not defined, which // people on Firefox can enable through a flag, so let's tell them. diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 037c5ca18..94dddf288 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -1,6 +1,7 @@ import { fileOpen, fileSave } from "./filesystem"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { + DEFAULT_FILENAME, EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES, @@ -71,6 +72,8 @@ export const saveAsJSON = async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, + /** filename */ + name: string = appState.name || DEFAULT_FILENAME, ) => { const serialized = serializeAsJSON(elements, appState, files, "local"); const blob = new Blob([serialized], { @@ -78,7 +81,7 @@ export const saveAsJSON = async ( }); const fileHandle = await fileSave(blob, { - name: appState.name, + name, extension: "excalidraw", description: "Excalidraw file", fileHandle: isImageFileHandle(appState.fileHandle) diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 7b936efc1..5e1af6c22 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -4,6 +4,7 @@ import { LibraryItem, ExcalidrawImperativeAPI, LibraryItemsSource, + LibraryItems_anyVersion, } from "../types"; import { restoreLibraryItems } from "./restore"; import type App from "../components/App"; @@ -23,13 +24,72 @@ import { LIBRARY_SIDEBAR_TAB, } from "../constants"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; -import { cloneJSON } from "../utils"; +import { + arrayToMap, + cloneJSON, + preventUnload, + promiseTry, + resolvablePromise, +} from "../utils"; +import { MaybePromise } from "../utility-types"; +import { Emitter } from "../emitter"; +import { Queue } from "../queue"; +import { hashElementsVersion, hashString } from "../element"; + +type LibraryUpdate = { + /** deleted library items since last onLibraryChange event */ + deletedItems: Map; + /** newly added items in the library */ + addedItems: Map; +}; + +// an object so that we can later add more properties to it without breaking, +// such as schema version +export type LibraryPersistedData = { libraryItems: LibraryItems }; + +const onLibraryUpdateEmitter = new Emitter< + [update: LibraryUpdate, libraryItems: LibraryItems] +>(); + +export type LibraryAdatapterSource = "load" | "save"; + +export interface LibraryPersistenceAdapter { + /** + * Should load data that were previously saved into the database using the + * `save` method. Should throw if saving fails. + * + * Will be used internally in multiple places, such as during save to + * in order to reconcile changes with latest store data. + */ + load(metadata: { + /** + * Indicates whether we're loading data for save purposes, or reading + * purposes, in which case host app can implement more aggressive caching. + */ + source: LibraryAdatapterSource; + }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + /** Should persist to the database as is (do no change the data structure). */ + save(libraryData: LibraryPersistedData): MaybePromise; +} + +export interface LibraryMigrationAdapter { + /** + * loads data from legacy data source. Returns `null` if no data is + * to be migrated. + */ + load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + + /** clears entire storage afterwards */ + clear(): MaybePromise; +} export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; + /** indicates whether library is initialized with library items (has gone + * through at least one update). Used in UI. Specific to this atom only. */ isInitialized: boolean; libraryItems: LibraryItems; -}>({ status: "loaded", isInitialized: true, libraryItems: [] }); +}>({ status: "loaded", isInitialized: false, libraryItems: [] }); const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => cloneJSON(libraryItems); @@ -74,12 +134,45 @@ export const mergeLibraryItems = ( return [...newItems, ...localItems]; }; +/** + * Returns { deletedItems, addedItems } maps of all added and deleted items + * since last onLibraryChange event. + * + * Host apps are recommended to diff with the latest state they have. + */ +const createLibraryUpdate = ( + prevLibraryItems: LibraryItems, + nextLibraryItems: LibraryItems, +): LibraryUpdate => { + const nextItemsMap = arrayToMap(nextLibraryItems); + + const update: LibraryUpdate = { + deletedItems: new Map(), + addedItems: new Map(), + }; + + for (const item of prevLibraryItems) { + if (!nextItemsMap.has(item.id)) { + update.deletedItems.set(item.id, item); + } + } + + const prevItemsMap = arrayToMap(prevLibraryItems); + + for (const item of nextLibraryItems) { + if (!prevItemsMap.has(item.id)) { + update.addedItems.set(item.id, item); + } + } + + return update; +}; + class Library { /** latest libraryItems */ - private lastLibraryItems: LibraryItems = []; - /** indicates whether library is initialized with library items (has gone - * though at least one update) */ - private isInitialized = false; + private currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ + private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); private app: App; @@ -95,21 +188,29 @@ class Library { private notifyListeners = () => { if (this.updateQueue.length > 0) { - jotaiStore.set(libraryItemsAtom, { + jotaiStore.set(libraryItemsAtom, (s) => ({ status: "loading", - libraryItems: this.lastLibraryItems, - isInitialized: this.isInitialized, - }); + libraryItems: this.currLibraryItems, + isInitialized: s.isInitialized, + })); } else { - this.isInitialized = true; jotaiStore.set(libraryItemsAtom, { status: "loaded", - libraryItems: this.lastLibraryItems, - isInitialized: this.isInitialized, + libraryItems: this.currLibraryItems, + isInitialized: true, }); try { - this.app.props.onLibraryChange?.( - cloneLibraryItems(this.lastLibraryItems), + const prevLibraryItems = this.prevLibraryItems; + this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems); + + const nextLibraryItems = cloneLibraryItems(this.currLibraryItems); + + this.app.props.onLibraryChange?.(nextLibraryItems); + + // for internal use in `useHandleLibrary` hook + onLibraryUpdateEmitter.trigger( + createLibraryUpdate(prevLibraryItems, nextLibraryItems), + nextLibraryItems, ); } catch (error) { console.error(error); @@ -119,9 +220,8 @@ class Library { /** call on excalidraw instance unmount */ destroy = () => { - this.isInitialized = false; this.updateQueue = []; - this.lastLibraryItems = []; + this.currLibraryItems = []; jotaiStore.set(libraryItemSvgsCache, new Map()); // TODO uncomment after/if we make jotai store scoped to each excal instance // jotaiStore.set(libraryItemsAtom, { @@ -142,14 +242,14 @@ class Library { return new Promise(async (resolve) => { try { const libraryItems = await (this.getLastUpdateTask() || - this.lastLibraryItems); + this.currLibraryItems); if (this.updateQueue.length > 0) { resolve(this.getLatestLibrary()); } else { resolve(cloneLibraryItems(libraryItems)); } } catch (error) { - return resolve(this.lastLibraryItems); + return resolve(this.currLibraryItems); } }); }; @@ -181,7 +281,7 @@ class Library { try { const source = await (typeof libraryItems === "function" && !(libraryItems instanceof Blob) - ? libraryItems(this.lastLibraryItems) + ? libraryItems(this.currLibraryItems) : libraryItems); let nextItems; @@ -207,7 +307,7 @@ class Library { } if (merge) { - resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); + resolve(mergeLibraryItems(this.currLibraryItems, nextItems)); } else { resolve(nextItems); } @@ -244,12 +344,12 @@ class Library { await this.getLastUpdateTask(); if (typeof libraryItems === "function") { - libraryItems = libraryItems(this.lastLibraryItems); + libraryItems = libraryItems(this.currLibraryItems); } - this.lastLibraryItems = cloneLibraryItems(await libraryItems); + this.currLibraryItems = cloneLibraryItems(await libraryItems); - resolve(this.lastLibraryItems); + resolve(this.currLibraryItems); } catch (error: any) { reject(error); } @@ -257,7 +357,7 @@ class Library { .catch((error) => { if (error.name === "AbortError") { console.warn("Library update aborted by user"); - return this.lastLibraryItems; + return this.currLibraryItems; } throw error; }) @@ -382,20 +482,165 @@ export const parseLibraryTokensFromUrl = () => { return libraryUrl ? { libraryUrl, idToken } : null; }; -export const useHandleLibrary = ({ - excalidrawAPI, - getInitialLibraryItems, -}: { - excalidrawAPI: ExcalidrawImperativeAPI | null; - getInitialLibraryItems?: () => LibraryItemsSource; -}) => { - const getInitialLibraryRef = useRef(getInitialLibraryItems); +class AdapterTransaction { + static queue = new Queue(); + + static async getLibraryItems( + adapter: LibraryPersistenceAdapter, + source: LibraryAdatapterSource, + _queue = true, + ): Promise { + const task = () => + new Promise(async (resolve, reject) => { + try { + const data = await adapter.load({ source }); + resolve(restoreLibraryItems(data?.libraryItems || [], "published")); + } catch (error: any) { + reject(error); + } + }); + + if (_queue) { + return AdapterTransaction.queue.push(task); + } + + return task(); + } + + static run = async ( + adapter: LibraryPersistenceAdapter, + fn: (transaction: AdapterTransaction) => Promise, + ) => { + const transaction = new AdapterTransaction(adapter); + return AdapterTransaction.queue.push(() => fn(transaction)); + }; + + // ------------------ + + private adapter: LibraryPersistenceAdapter; + + constructor(adapter: LibraryPersistenceAdapter) { + this.adapter = adapter; + } + + getLibraryItems(source: LibraryAdatapterSource) { + return AdapterTransaction.getLibraryItems(this.adapter, source, false); + } +} + +let lastSavedLibraryItemsHash = 0; +let librarySaveCounter = 0; + +export const getLibraryItemsHash = (items: LibraryItems) => { + return hashString( + items + .map((item) => { + return `${item.id}:${hashElementsVersion(item.elements)}`; + }) + .sort() + .join(), + ); +}; + +const persistLibraryUpdate = async ( + adapter: LibraryPersistenceAdapter, + update: LibraryUpdate, +): Promise => { + try { + librarySaveCounter++; + + return await AdapterTransaction.run(adapter, async (transaction) => { + const nextLibraryItemsMap = arrayToMap( + await transaction.getLibraryItems("save"), + ); + + for (const [id] of update.deletedItems) { + nextLibraryItemsMap.delete(id); + } + + const addedItems: LibraryItem[] = []; + + // we want to merge current library items with the ones stored in the + // DB so that we don't lose any elements that for some reason aren't + // in the current editor library, which could happen when: + // + // 1. we haven't received an update deleting some elements + // (in which case it's still better to keep them in the DB lest + // it was due to a different reason) + // 2. we keep a single DB for all active editors, but the editors' + // libraries aren't synced or there's a race conditions during + // syncing + // 3. some other race condition, e.g. during init where emit updates + // for partial updates (e.g. you install a 3rd party library and + // init from DB only after — we emit events for both updates) + for (const [id, item] of update.addedItems) { + if (nextLibraryItemsMap.has(id)) { + // replace item with latest version + // TODO we could prefer the newer item instead + nextLibraryItemsMap.set(id, item); + } else { + // we want to prepend the new items with the ones that are already + // in DB to preserve the ordering we do in editor (newly added + // items are added to the beginning) + addedItems.push(item); + } + } + + const nextLibraryItems = addedItems.concat( + Array.from(nextLibraryItemsMap.values()), + ); + + const version = getLibraryItemsHash(nextLibraryItems); + + if (version !== lastSavedLibraryItemsHash) { + await adapter.save({ libraryItems: nextLibraryItems }); + } + + lastSavedLibraryItemsHash = version; + + return nextLibraryItems; + }); + } finally { + librarySaveCounter--; + } +}; + +export const useHandleLibrary = ( + opts: { + excalidrawAPI: ExcalidrawImperativeAPI | null; + } & ( + | { + /** @deprecated we recommend using `opts.adapter` instead */ + getInitialLibraryItems?: () => MaybePromise; + } + | { + adapter: LibraryPersistenceAdapter; + /** + * Adapter that takes care of loading data from legacy data store. + * Supply this if you want to migrate data on initial load from legacy + * data store. + * + * Can be a different LibraryPersistenceAdapter. + */ + migrationAdapter?: LibraryMigrationAdapter; + } + ), +) => { + const { excalidrawAPI } = opts; + + const optsRef = useRef(opts); + optsRef.current = opts; + + const isLibraryLoadedRef = useRef(false); useEffect(() => { if (!excalidrawAPI) { return; } + // reset on editor remount (excalidrawAPI changed) + isLibraryLoadedRef.current = false; + const importLibraryFromURL = async ({ libraryUrl, idToken, @@ -463,23 +708,209 @@ export const useHandleLibrary = ({ }; // ------------------------------------------------------------------------- - // ------ init load -------------------------------------------------------- - if (getInitialLibraryRef.current) { - excalidrawAPI.updateLibrary({ - libraryItems: getInitialLibraryRef.current(), - }); - } + // ---------------------------------- init --------------------------------- + // ------------------------------------------------------------------------- const libraryUrlTokens = parseLibraryTokensFromUrl(); if (libraryUrlTokens) { importLibraryFromURL(libraryUrlTokens); } + + // ------ (A) init load (legacy) ------------------------------------------- + if ( + "getInitialLibraryItems" in optsRef.current && + optsRef.current.getInitialLibraryItems + ) { + console.warn( + "useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.", + ); + + Promise.resolve(optsRef.current.getInitialLibraryItems()) + .then((libraryItems) => { + excalidrawAPI.updateLibrary({ + libraryItems, + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }); + }) + .catch((error: any) => { + console.error( + `UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`, + ); + }); + } + + // ------------------------------------------------------------------------- // --------------------------------------------------------- init load ----- + // ------------------------------------------------------------------------- + + // ------ (B) data source adapter ------------------------------------------ + + if ("adapter" in optsRef.current && optsRef.current.adapter) { + const adapter = optsRef.current.adapter; + const migrationAdapter = optsRef.current.migrationAdapter; + + const initDataPromise = resolvablePromise(); + + // migrate from old data source if needed + // (note, if `migrate` function is defined, we always migrate even + // if the data has already been migrated. In that case it'll be a no-op, + // though with several unnecessary steps — we will still load latest + // DB data during the `persistLibraryChange()` step) + // ----------------------------------------------------------------------- + if (migrationAdapter) { + initDataPromise.resolve( + promiseTry(migrationAdapter.load) + .then(async (libraryData) => { + let restoredData: LibraryItems | null = null; + try { + // if no library data to migrate, assume no migration needed + // and skip persisting to new data store, as well as well + // clearing the old store via `migrationAdapter.clear()` + if (!libraryData) { + return AdapterTransaction.getLibraryItems(adapter, "load"); + } + + restoredData = restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ); + + // we don't queue this operation because it's running inside + // a promise that's running inside Library update queue itself + const nextItems = await persistLibraryUpdate( + adapter, + createLibraryUpdate([], restoredData), + ); + try { + await migrationAdapter.clear(); + } catch (error: any) { + console.error( + `couldn't delete legacy library data: ${error.message}`, + ); + } + // migration suceeded, load migrated data + return nextItems; + } catch (error: any) { + console.error( + `couldn't migrate legacy library data: ${error.message}`, + ); + // migration failed, load data from previous store, if any + return restoredData; + } + }) + // errors caught during `migrationAdapter.load()` + .catch((error: any) => { + console.error(`error during library migration: ${error.message}`); + // as a default, load latest library from current data source + return AdapterTransaction.getLibraryItems(adapter, "load"); + }), + ); + } else { + initDataPromise.resolve( + promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), + ); + } + + // load initial (or migrated) library + excalidrawAPI + .updateLibrary({ + libraryItems: initDataPromise.then((libraryItems) => { + const _libraryItems = libraryItems || []; + lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems); + return _libraryItems; + }), + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }) + .finally(() => { + isLibraryLoadedRef.current = true; + }); + } + // ---------------------------------------------- data source datapter ----- window.addEventListener(EVENT.HASHCHANGE, onHashChange); return () => { window.removeEventListener(EVENT.HASHCHANGE, onHashChange); }; - }, [excalidrawAPI]); + }, [ + // important this useEffect only depends on excalidrawAPI so it only reruns + // on editor remounts (the excalidrawAPI changes) + excalidrawAPI, + ]); + + // This effect is run without excalidrawAPI dependency so that host apps + // can run this hook outside of an active editor instance and the library + // update queue/loop survives editor remounts + // + // This effect is still only meant to be run if host apps supply an persitence + // adapter. If we don't have access to it, it the update listener doesn't + // do anything. + useEffect( + () => { + // on update, merge with current library items and persist + // ----------------------------------------------------------------------- + const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on( + async (update, nextLibraryItems) => { + const isLoaded = isLibraryLoadedRef.current; + // we want to operate with the latest adapter, but we don't want this + // effect to rerun on every adapter change in case host apps' adapter + // isn't stable + const adapter = + ("adapter" in optsRef.current && optsRef.current.adapter) || null; + try { + if (adapter) { + if ( + // if nextLibraryItems hash identical to previously saved hash, + // exit early, even if actual upstream state ends up being + // different (e.g. has more data than we have locally), as it'd + // be low-impact scenario. + lastSavedLibraryItemsHash !== + getLibraryItemsHash(nextLibraryItems) + ) { + await persistLibraryUpdate(adapter, update); + } + } + } catch (error: any) { + console.error( + `couldn't persist library update: ${error.message}`, + update, + ); + + // currently we only show error if an editor is loaded + if (isLoaded && optsRef.current.excalidrawAPI) { + optsRef.current.excalidrawAPI.updateScene({ + appState: { + errorMessage: t("errors.saveLibraryError"), + }, + }); + } + } + }, + ); + + const onUnload = (event: Event) => { + if (librarySaveCounter) { + preventUnload(event); + } + }; + + window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload); + + return () => { + window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload); + unsubOnLibraryUpdate(); + lastSavedLibraryItemsHash = 0; + librarySaveCounter = 0; + }; + }, + [ + // this effect must not have any deps so it doesn't rerun + ], + ); }; diff --git a/packages/excalidraw/data/magic.ts b/packages/excalidraw/data/magic.ts index 26c38ca65..024e2b116 100644 --- a/packages/excalidraw/data/magic.ts +++ b/packages/excalidraw/data/magic.ts @@ -1,3 +1,4 @@ +import { THEME } from "../constants"; import { Theme } from "../element/types"; import { DataURL } from "../types"; import { OpenAIInput, OpenAIOutput } from "./ai/types"; @@ -39,7 +40,7 @@ export async function diagramToHTML({ image, apiKey, text, - theme = "light", + theme = THEME.LIGHT, }: { image: DataURL; apiKey: string; diff --git a/packages/excalidraw/data/reconcile.ts b/packages/excalidraw/data/reconcile.ts new file mode 100644 index 000000000..95d6e4669 --- /dev/null +++ b/packages/excalidraw/data/reconcile.ts @@ -0,0 +1,79 @@ +import { OrderedExcalidrawElement } from "../element/types"; +import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex"; +import { AppState } from "../types"; +import { MakeBrand } from "../utility-types"; +import { arrayToMap } from "../utils"; + +export type ReconciledExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"ReconciledElement">; + +export type RemoteExcalidrawElement = OrderedExcalidrawElement & + MakeBrand<"RemoteExcalidrawElement">; + +const shouldDiscardRemoteElement = ( + localAppState: AppState, + local: OrderedExcalidrawElement | undefined, + remote: RemoteExcalidrawElement, +): boolean => { + if ( + local && + // local element is being edited + (local.id === localAppState.editingElement?.id || + local.id === localAppState.resizingElement?.id || + local.id === localAppState.draggingElement?.id || + // local element is newer + local.version > remote.version || + // resolve conflicting edits deterministically by taking the one with + // the lowest versionNonce + (local.version === remote.version && + local.versionNonce < remote.versionNonce)) + ) { + return true; + } + return false; +}; + +export const reconcileElements = ( + localElements: readonly OrderedExcalidrawElement[], + remoteElements: readonly RemoteExcalidrawElement[], + localAppState: AppState, +): ReconciledExcalidrawElement[] => { + const localElementsMap = arrayToMap(localElements); + const reconciledElements: OrderedExcalidrawElement[] = []; + const added = new Set(); + + // process remote elements + for (const remoteElement of remoteElements) { + if (!added.has(remoteElement.id)) { + const localElement = localElementsMap.get(remoteElement.id); + const discardRemoteElement = shouldDiscardRemoteElement( + localAppState, + localElement, + remoteElement, + ); + + if (localElement && discardRemoteElement) { + reconciledElements.push(localElement); + added.add(localElement.id); + } else { + reconciledElements.push(remoteElement); + added.add(remoteElement.id); + } + } + } + + // process remaining local elements + for (const localElement of localElements) { + if (!added.has(localElement.id)) { + reconciledElements.push(localElement); + added.add(localElement.id); + } + } + + const orderedElements = orderByFractionalIndex(reconciledElements); + + // de-duplicate indices + syncInvalidIndices(orderedElements); + + return orderedElements as ReconciledExcalidrawElement[]; +}; diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts index 0998fd3c7..c73890e22 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -7,8 +7,9 @@ export const resaveAsImageWithScene = async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, + name: string, ) => { - const { exportBackground, viewBackgroundColor, name, fileHandle } = appState; + const { exportBackground, viewBackgroundColor, fileHandle } = appState; const fileHandleType = getFileHandleType(fileHandle); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 76a8c32ca..d8cadeac8 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -4,6 +4,7 @@ import { ExcalidrawSelectionElement, ExcalidrawTextElement, FontFamilyValues, + OrderedExcalidrawElement, PointBinding, StrokeRoundness, } from "../element/types"; @@ -26,7 +27,6 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_TEXT_ALIGN, DEFAULT_VERTICAL_ALIGN, - PRECEDING_ELEMENT_KEY, FONT_FAMILY, ROUNDNESS, DEFAULT_SIDEBAR, @@ -35,15 +35,16 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, + getContainerElement, getDefaultLineHeight, - measureBaseline, } from "../element/textElement"; import { normalizeLink } from "./url"; +import { syncInvalidIndices } from "../fractionalIndex"; type RestoredAppState = Omit< AppState, @@ -73,7 +74,7 @@ export const AllowedExcalidrawActiveTools: Record< }; export type RestoredDataState = { - elements: ExcalidrawElement[]; + elements: OrderedExcalidrawElement[]; appState: RestoredAppState; files: BinaryFiles; }; @@ -101,8 +102,6 @@ const restoreElementWithProperties = < boundElementIds?: readonly ExcalidrawElement["id"][]; /** @deprecated */ strokeSharpness?: StrokeRoundness; - /** metadata that may be present in elements during collaboration */ - [PRECEDING_ELEMENT_KEY]?: string; }, K extends Pick, keyof ExcalidrawElement>>, >( @@ -115,14 +114,13 @@ const restoreElementWithProperties = < > & Partial>, ): T => { - const base: Pick & { - [PRECEDING_ELEMENT_KEY]?: string; - } = { + const base: Pick = { type: extra.type || element.type, // all elements must have version > 0 so getSceneVersion() will pick up // newly added elements version: element.version || 1, versionNonce: element.versionNonce ?? 0, + index: element.index ?? null, isDeleted: element.isDeleted ?? false, id: element.id || randomId(), fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle, @@ -166,10 +164,6 @@ const restoreElementWithProperties = < "customData" in extra ? extra.customData : element.customData; } - if (PRECEDING_ELEMENT_KEY in element) { - base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY]; - } - return { ...base, ...getNormalizedDimensions(base), @@ -179,7 +173,6 @@ const restoreElementWithProperties = < const restoreElement = ( element: Exclude, - refreshDimensions = false, ): typeof element | null => { switch (element.type) { case "text": @@ -207,11 +200,6 @@ const restoreElement = ( : // no element height likely means programmatic use, so default // to a fixed line height getDefaultLineHeight(element.fontFamily)); - const baseline = measureBaseline( - element.text, - getFontString(element), - lineHeight, - ); element = restoreElementWithProperties(element, { fontSize, fontFamily, @@ -222,7 +210,6 @@ const restoreElement = ( originalText: element.originalText || text, lineHeight, - baseline, }); // if empty text, mark as deleted. We keep in array @@ -232,10 +219,6 @@ const restoreElement = ( element = bumpVersion(element); } - if (refreshDimensions) { - element = { ...element, ...refreshTextDimensions(element) }; - } - return element; case "freedraw": { return restoreElementWithProperties(element, { @@ -295,11 +278,8 @@ const restoreElement = ( case "rectangle": case "diamond": case "iframe": - return restoreElementWithProperties(element, {}); case "embeddable": - return restoreElementWithProperties(element, { - validated: null, - }); + return restoreElementWithProperties(element, {}); case "magicframe": case "frame": return restoreElementWithProperties(element, { @@ -421,33 +401,35 @@ export const restoreElements = ( /** NOTE doesn't serve for reconciliation */ localElements: readonly ExcalidrawElement[] | null | undefined, opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, -): ExcalidrawElement[] => { +): OrderedExcalidrawElement[] => { // used to detect duplicate top-level element ids const existingIds = new Set(); const localElementsMap = localElements ? arrayToMap(localElements) : null; - const restoredElements = (elements || []).reduce((elements, element) => { - // filtering out selection, which is legacy, no longer kept in elements, - // and causing issues if retained - if (element.type !== "selection" && !isInvisiblySmallElement(element)) { - let migratedElement: ExcalidrawElement | null = restoreElement( - element, - opts?.refreshDimensions, - ); - if (migratedElement) { - const localElement = localElementsMap?.get(element.id); - if (localElement && localElement.version > migratedElement.version) { - migratedElement = bumpVersion(migratedElement, localElement.version); - } - if (existingIds.has(migratedElement.id)) { - migratedElement = { ...migratedElement, id: randomId() }; - } - existingIds.add(migratedElement.id); + const restoredElements = syncInvalidIndices( + (elements || []).reduce((elements, element) => { + // filtering out selection, which is legacy, no longer kept in elements, + // and causing issues if retained + if (element.type !== "selection" && !isInvisiblySmallElement(element)) { + let migratedElement: ExcalidrawElement | null = restoreElement(element); + if (migratedElement) { + const localElement = localElementsMap?.get(element.id); + if (localElement && localElement.version > migratedElement.version) { + migratedElement = bumpVersion( + migratedElement, + localElement.version, + ); + } + if (existingIds.has(migratedElement.id)) { + migratedElement = { ...migratedElement, id: randomId() }; + } + existingIds.add(migratedElement.id); - elements.push(migratedElement); + elements.push(migratedElement); + } } - } - return elements; - }, [] as ExcalidrawElement[]); + return elements; + }, [] as ExcalidrawElement[]), + ); if (!opts?.repairBindings) { return restoredElements; @@ -465,6 +447,17 @@ export const restoreElements = ( } else if (element.boundElements) { repairContainerElement(element, restoredElementsMap); } + + if (opts.refreshDimensions && isTextElement(element)) { + Object.assign( + element, + refreshTextDimensions( + element, + getContainerElement(element, restoredElementsMap), + restoredElementsMap, + ), + ); + } } return restoredElements; diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 7c71f33f8..4d58fbac6 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -152,14 +152,14 @@ describe("Test Transform", () => { strokeStyle: "dotted", }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(4); + expect(excalidrawElements.length).toBe(4); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -235,14 +235,14 @@ describe("Test Transform", () => { }, }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(12); + expect(excalidrawElements.length).toBe(12); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -293,14 +293,14 @@ describe("Test Transform", () => { }, }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(8); + expect(excalidrawElements.length).toBe(8); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -338,13 +338,13 @@ describe("Test Transform", () => { name: "My frame", }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elementsSkeleton, opts, ); - expect(excaldrawElements.length).toBe(4); + expect(excalidrawElements.length).toBe(4); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchObject({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -383,11 +383,11 @@ describe("Test Transform", () => { height: 100, }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elementsSkeleton, opts, ); - const frame = excaldrawElements.find((ele) => ele.type === "frame")!; + const frame = excalidrawElements.find((ele) => ele.type === "frame")!; expect(frame.width).toBe(800); expect(frame.height).toBe(126); }); @@ -411,13 +411,13 @@ describe("Test Transform", () => { }, }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(4); - const [arrow, text, rectangle, ellipse] = excaldrawElements; + expect(excalidrawElements.length).toBe(4); + const [arrow, text, rectangle, ellipse] = excalidrawElements; expect(arrow).toMatchObject({ type: "arrow", x: 255, @@ -466,7 +466,7 @@ describe("Test Transform", () => { ], }); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -495,13 +495,13 @@ describe("Test Transform", () => { }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(4); - const [arrow, text1, text2, text3] = excaldrawElements; + expect(excalidrawElements.length).toBe(4); + const [arrow, text1, text2, text3] = excalidrawElements; expect(arrow).toMatchObject({ type: "arrow", @@ -551,7 +551,7 @@ describe("Test Transform", () => { ], }); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -611,14 +611,14 @@ describe("Test Transform", () => { }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(5); + expect(excalidrawElements.length).toBe(5); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -660,14 +660,14 @@ describe("Test Transform", () => { }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(4); + expect(excalidrawElements.length).toBe(4); - excaldrawElements.forEach((ele) => { + excalidrawElements.forEach((ele) => { expect(ele).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), @@ -714,13 +714,13 @@ describe("Test Transform", () => { }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(4); - const [, , arrow, text] = excaldrawElements; + expect(excalidrawElements.length).toBe(4); + const [, , arrow, text] = excalidrawElements; expect(arrow).toMatchObject({ type: "arrow", x: 255, @@ -765,12 +765,12 @@ describe("Test Transform", () => { backgroundColor: "#bac8ff", }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(2); - const [arrow, rect] = excaldrawElements; + expect(excalidrawElements.length).toBe(2); + const [arrow, rect] = excalidrawElements; expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", focus: 0, @@ -808,13 +808,13 @@ describe("Test Transform", () => { height: 200, }, ]; - const excaldrawElements = convertToExcalidrawElements( + const excalidrawElements = convertToExcalidrawElements( elements as ExcalidrawElementSkeleton[], opts, ); - expect(excaldrawElements.length).toBe(1); - expect(excaldrawElements[0]).toMatchSnapshot({ + expect(excalidrawElements.length).toBe(1); + expect(excalidrawElements[0]).toMatchSnapshot({ seed: expect.any(Number), versionNonce: expect.any(Number), }); @@ -822,4 +822,148 @@ describe("Test Transform", () => { "Duplicate id found for rect-1", ); }); + + it("should contains customData if provided", () => { + const rawData = [ + { + type: "rectangle", + x: 100, + y: 100, + customData: { createdBy: "user01" }, + }, + ]; + const convertedElements = convertToExcalidrawElements( + rawData as ExcalidrawElementSkeleton[], + opts, + ); + expect(convertedElements[0].customData).toStrictEqual({ + createdBy: "user01", + }); + }); + + it("should transform the elements correctly when linear elements have single point", () => { + const elements: ExcalidrawElementSkeleton[] = [ + { + id: "B", + type: "rectangle", + groupIds: ["subgraph_group_B"], + x: 0, + y: 0, + width: 166.03125, + height: 163, + label: { + groupIds: ["subgraph_group_B"], + text: "B", + fontSize: 20, + verticalAlign: "top", + }, + }, + { + id: "A", + type: "rectangle", + groupIds: ["subgraph_group_A"], + x: 364.546875, + y: 0, + width: 120.265625, + height: 114, + label: { + groupIds: ["subgraph_group_A"], + text: "A", + fontSize: 20, + verticalAlign: "top", + }, + }, + { + id: "Alice", + type: "rectangle", + groupIds: ["subgraph_group_A"], + x: 389.546875, + y: 35, + width: 70.265625, + height: 44, + strokeWidth: 2, + label: { + groupIds: ["subgraph_group_A"], + text: "Alice", + fontSize: 20, + }, + link: null, + }, + { + id: "Bob", + type: "rectangle", + groupIds: ["subgraph_group_B"], + x: 54.76953125, + y: 35, + width: 56.4921875, + height: 44, + strokeWidth: 2, + label: { + groupIds: ["subgraph_group_B"], + text: "Bob", + fontSize: 20, + }, + link: null, + }, + { + id: "Bob_Alice", + type: "arrow", + groupIds: [], + x: 111.262, + y: 57, + strokeWidth: 2, + points: [ + [0, 0], + [272.985, 0], + ], + label: { + text: "How are you?", + fontSize: 20, + groupIds: [], + }, + roundness: { + type: 2, + }, + start: { + id: "Bob", + }, + end: { + id: "Alice", + }, + }, + { + id: "Bob_B", + type: "arrow", + groupIds: [], + x: 77.017, + y: 79, + strokeWidth: 2, + points: [[0, 0]], + label: { + text: "Friendship", + fontSize: 20, + groupIds: [], + }, + roundness: { + type: 2, + }, + start: { + id: "Bob", + }, + end: { + id: "B", + }, + }, + ]; + + const excalidrawElements = convertToExcalidrawElements(elements, opts); + expect(excalidrawElements.length).toBe(12); + excalidrawElements.forEach((ele) => { + expect(ele).toMatchSnapshot({ + seed: expect.any(Number), + versionNonce: expect.any(Number), + id: expect.any(String), + }); + }); + }); }); diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 7b5286923..79f9af962 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -24,6 +24,7 @@ import { normalizeText, } from "../element/textElement"; import { + ElementsMap, ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElement, @@ -38,13 +39,21 @@ import { ExcalidrawTextElement, FileId, FontFamilyValues, + NonDeletedSceneElementsMap, TextAlign, VerticalAlign, } from "../element/types"; import { MarkOptional } from "../utility-types"; -import { assertNever, cloneJSON, getFontString } from "../utils"; +import { + arrayToMap, + assertNever, + cloneJSON, + getFontString, + toBrandedType, +} from "../utils"; import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; +import { syncInvalidIndices } from "../fractionalIndex"; export type ValidLinearElement = { type: "arrow" | "line"; @@ -202,6 +211,7 @@ const DEFAULT_DIMENSION = 100; const bindTextToContainer = ( container: ExcalidrawElement, textProps: { text: string } & MarkOptional, + elementsMap: ElementsMap, ) => { const textElement: ExcalidrawTextElement = newTextElement({ x: 0, @@ -220,7 +230,7 @@ const bindTextToContainer = ( }), }); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox(textElement, container, elementsMap); return [container, textElement] as const; }; @@ -229,6 +239,7 @@ const bindLinearElementToElement = ( start: ValidLinearElement["start"], end: ValidLinearElement["end"], elementStore: ElementStore, + elementsMap: NonDeletedSceneElementsMap, ): { linearElement: ExcalidrawLinearElement; startBoundElement?: ExcalidrawElement; @@ -314,6 +325,7 @@ const bindLinearElementToElement = ( linearElement, startBoundElement as ExcalidrawBindableElement, "start", + elementsMap, ); } } @@ -388,15 +400,26 @@ const bindLinearElementToElement = ( linearElement, endBoundElement as ExcalidrawBindableElement, "end", + elementsMap, ); } } + // Safe check to early return for single point + if (linearElement.points.length < 2) { + return { + linearElement, + startBoundElement, + endBoundElement, + }; + } + // Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates. const endPointIndex = linearElement.points.length - 1; const delta = 0.5; const newPoints = cloneJSON(linearElement.points) as [number, number][]; + // left to right so shift the arrow towards right if ( linearElement.points[endPointIndex][0] > @@ -451,8 +474,15 @@ class ElementStore { this.excalidrawElements.set(ele.id, ele); }; + getElements = () => { - return Array.from(this.excalidrawElements.values()); + return syncInvalidIndices(Array.from(this.excalidrawElements.values())); + }; + + getElementsMap = () => { + return toBrandedType( + arrayToMap(this.getElements()), + ); }; getElement = (id: string) => { @@ -610,6 +640,7 @@ export const convertToExcalidrawElements = ( } } + const elementsMap = elementStore.getElementsMap(); // Add labels and arrow bindings for (const [id, element] of elementsWithIds) { const excalidrawElement = elementStore.getElement(id)!; @@ -623,6 +654,7 @@ export const convertToExcalidrawElements = ( let [container, text] = bindTextToContainer( excalidrawElement, element?.label, + elementsMap, ); elementStore.add(container); elementStore.add(text); @@ -650,6 +682,7 @@ export const convertToExcalidrawElements = ( originalStart, originalEnd, elementStore, + elementsMap, ); container = linearElement; elementStore.add(linearElement); @@ -674,6 +707,7 @@ export const convertToExcalidrawElements = ( start, end, elementStore, + elementsMap, ); elementStore.add(linearElement); diff --git a/packages/excalidraw/data/url.ts b/packages/excalidraw/data/url.ts index 2655c141d..dae576068 100644 --- a/packages/excalidraw/data/url.ts +++ b/packages/excalidraw/data/url.ts @@ -1,11 +1,15 @@ import { sanitizeUrl } from "@braintree/sanitize-url"; +export const sanitizeHTMLAttribute = (html: string) => { + return html.replace(/"/g, """); +}; + export const normalizeLink = (link: string) => { link = link.trim(); if (!link) { return link; } - return sanitizeUrl(link); + return sanitizeUrl(sanitizeHTMLAttribute(link)); }; export const isLocalLink = (link: string | null) => { diff --git a/packages/excalidraw/deburr.ts b/packages/excalidraw/deburr.ts new file mode 100644 index 000000000..ba95eddc8 --- /dev/null +++ b/packages/excalidraw/deburr.ts @@ -0,0 +1,93 @@ +// taken from lodash (MIT) +// https://github.com/lodash/lodash/blob/67389a8c78975d97505fa15aa79bec6397749807/lodash.js#L14180 + +const rsComboMarksRange = "\\u0300-\\u036f"; +const reComboHalfMarksRange = "\\ufe20-\\ufe2f"; +const rsComboSymbolsRange = "\\u20d0-\\u20ff"; +const rsComboRange = + rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange; +const rsCombo = `[${rsComboRange}]`; + +const reComboMark = RegExp(rsCombo, "g"); + +const reLatin = /[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g; + +// NOTE below letter replacements are modified from lodash to always convert +// to single-letter form by phonetic similarity to keep indexing identical. +// Doing this is only useful for search highlighting, and only insofar +// we use a library that can highlight the original source string using +// the matching indices. As such, we'll likely need to write our own highlighter +// anyway. Ultimately, we'll want to write our own matcher altogether +// so we don't have to do any deburring, which will be the most correct +// solution. +// +// prettier-ignore +const deburredLetters = { + '\xc0': 'A', '\xc1': 'A', '\xc2': 'A', '\xc3': 'A', '\xc4': 'A', '\xc5': 'A', + '\xe0': 'a', '\xe1': 'a', '\xe2': 'a', '\xe3': 'a', '\xe4': 'a', '\xe5': 'a', + '\xc7': 'C', '\xe7': 'c', + '\xd0': 'D', '\xf0': 'd', + '\xc8': 'E', '\xc9': 'E', '\xca': 'E', '\xcb': 'E', + '\xe8': 'e', '\xe9': 'e', '\xea': 'e', '\xeb': 'e', + '\xcc': 'I', '\xcd': 'I', '\xce': 'I', '\xcf': 'I', + '\xec': 'i', '\xed': 'i', '\xee': 'i', '\xef': 'i', + '\xd1': 'N', '\xf1': 'n', + '\xd2': 'O', '\xd3': 'O', '\xd4': 'O', '\xd5': 'O', '\xd6': 'O', '\xd8': 'O', + '\xf2': 'o', '\xf3': 'o', '\xf4': 'o', '\xf5': 'o', '\xf6': 'o', '\xf8': 'o', + '\xd9': 'U', '\xda': 'U', '\xdb': 'U', '\xdc': 'U', + '\xf9': 'u', '\xfa': 'u', '\xfb': 'u', '\xfc': 'u', + '\xdd': 'Y', '\xfd': 'y', '\xff': 'y', + // normaly Ae/ae + '\xc6': 'E', '\xe6': 'e', + // normally Th/th + '\xde': 'T', '\xfe': 't', + // normally ss + '\xdf': 's', + '\u0100': 'A', '\u0102': 'A', '\u0104': 'A', + '\u0101': 'a', '\u0103': 'a', '\u0105': 'a', + '\u0106': 'C', '\u0108': 'C', '\u010a': 'C', '\u010c': 'C', + '\u0107': 'c', '\u0109': 'c', '\u010b': 'c', '\u010d': 'c', + '\u010e': 'D', '\u0110': 'D', '\u010f': 'd', '\u0111': 'd', + '\u0112': 'E', '\u0114': 'E', '\u0116': 'E', '\u0118': 'E', '\u011a': 'E', + '\u0113': 'e', '\u0115': 'e', '\u0117': 'e', '\u0119': 'e', '\u011b': 'e', + '\u011c': 'G', '\u011e': 'G', '\u0120': 'G', '\u0122': 'G', + '\u011d': 'g', '\u011f': 'g', '\u0121': 'g', '\u0123': 'g', + '\u0124': 'H', '\u0126': 'H', '\u0125': 'h', '\u0127': 'h', + '\u0128': 'I', '\u012a': 'I', '\u012c': 'I', '\u012e': 'I', '\u0130': 'I', + '\u0129': 'i', '\u012b': 'i', '\u012d': 'i', '\u012f': 'i', '\u0131': 'i', + '\u0134': 'J', '\u0135': 'j', + '\u0136': 'K', '\u0137': 'k', '\u0138': 'k', + '\u0139': 'L', '\u013b': 'L', '\u013d': 'L', '\u013f': 'L', '\u0141': 'L', + '\u013a': 'l', '\u013c': 'l', '\u013e': 'l', '\u0140': 'l', '\u0142': 'l', + '\u0143': 'N', '\u0145': 'N', '\u0147': 'N', '\u014a': 'N', + '\u0144': 'n', '\u0146': 'n', '\u0148': 'n', '\u014b': 'n', + '\u014c': 'O', '\u014e': 'O', '\u0150': 'O', + '\u014d': 'o', '\u014f': 'o', '\u0151': 'o', + '\u0154': 'R', '\u0156': 'R', '\u0158': 'R', + '\u0155': 'r', '\u0157': 'r', '\u0159': 'r', + '\u015a': 'S', '\u015c': 'S', '\u015e': 'S', '\u0160': 'S', + '\u015b': 's', '\u015d': 's', '\u015f': 's', '\u0161': 's', + '\u0162': 'T', '\u0164': 'T', '\u0166': 'T', + '\u0163': 't', '\u0165': 't', '\u0167': 't', + '\u0168': 'U', '\u016a': 'U', '\u016c': 'U', '\u016e': 'U', '\u0170': 'U', '\u0172': 'U', + '\u0169': 'u', '\u016b': 'u', '\u016d': 'u', '\u016f': 'u', '\u0171': 'u', '\u0173': 'u', + '\u0174': 'W', '\u0175': 'w', + '\u0176': 'Y', '\u0177': 'y', '\u0178': 'Y', + '\u0179': 'Z', '\u017b': 'Z', '\u017d': 'Z', + '\u017a': 'z', '\u017c': 'z', '\u017e': 'z', + // normally IJ/ij + '\u0132': 'I', '\u0133': 'i', + // normally OE/oe + '\u0152': 'E', '\u0153': 'e', + // normally "'n" + '\u0149': "n", + '\u017f': 's' + }; + +export const deburr = (str: string) => { + return str + .replace(reLatin, (key: string) => { + return deburredLetters[key as keyof typeof deburredLetters] || key; + }) + .replace(reComboMark, ""); +}; diff --git a/packages/excalidraw/distribute.ts b/packages/excalidraw/distribute.ts index acad09b2d..368b2f24d 100644 --- a/packages/excalidraw/distribute.ts +++ b/packages/excalidraw/distribute.ts @@ -1,7 +1,7 @@ -import { ExcalidrawElement } from "./element/types"; import { newElementWith } from "./element/mutateElement"; import { getMaximumGroups } from "./groups"; import { getCommonBoundingBox } from "./element/bounds"; +import type { ElementsMap, ExcalidrawElement } from "./element/types"; export interface Distribution { space: "between"; @@ -10,6 +10,7 @@ export interface Distribution { export const distributeElements = ( selectedElements: ExcalidrawElement[], + elementsMap: ElementsMap, distribution: Distribution, ): ExcalidrawElement[] => { const [start, mid, end, extent] = @@ -18,7 +19,7 @@ export const distributeElements = ( : (["minY", "midY", "maxY", "height"] as const); const bounds = getCommonBoundingBox(selectedElements); - const groups = getMaximumGroups(selectedElements) + const groups = getMaximumGroups(selectedElements, elementsMap) .map((group) => [group, getCommonBoundingBox(group)] as const) .sort((a, b) => a[1][mid] - b[1][mid]); diff --git a/packages/excalidraw/element/ElementCanvasButtons.tsx b/packages/excalidraw/element/ElementCanvasButtons.tsx index 99d9d55e1..0fc7621fd 100644 --- a/packages/excalidraw/element/ElementCanvasButtons.tsx +++ b/packages/excalidraw/element/ElementCanvasButtons.tsx @@ -1,6 +1,6 @@ import { AppState } from "../types"; import { sceneCoordsToViewportCoords } from "../utils"; -import { NonDeletedExcalidrawElement } from "./types"; +import { ElementsMap, NonDeletedExcalidrawElement } from "./types"; import { getElementAbsoluteCoords } from "."; import { useExcalidrawAppState } from "../components/App"; @@ -11,8 +11,9 @@ const CONTAINER_PADDING = 5; const getContainerCoords = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( { sceneX: x1 + element.width, sceneY: y1 }, appState, @@ -25,9 +26,11 @@ const getContainerCoords = ( export const ElementCanvasButtons = ({ children, element, + elementsMap, }: { children: React.ReactNode; element: NonDeletedExcalidrawElement; + elementsMap: ElementsMap; }) => { const appState = useExcalidrawAppState(); @@ -42,7 +45,7 @@ export const ElementCanvasButtons = ({ return null; } - const { x, y } = getContainerCoords(element, appState); + const { x, y } = getContainerCoords(element, appState, elementsMap); return (
, startBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep", + elementsMap: NonDeletedSceneElementsMap, ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -76,6 +88,7 @@ export const bindOrUnbindLinearElement = ( "start", boundToElementIds, unboundFromElementIds, + elementsMap, ); bindOrUnbindLinearElementEdge( linearElement, @@ -84,6 +97,7 @@ export const bindOrUnbindLinearElement = ( "end", boundToElementIds, unboundFromElementIds, + elementsMap, ); const onlyUnbound = Array.from(unboundFromElementIds).filter( @@ -111,6 +125,7 @@ const bindOrUnbindLinearElementEdge = ( boundToElementIds: Set, // Is mutated unboundFromElementIds: Set, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (bindableElement !== "keep") { if (bindableElement != null) { @@ -127,7 +142,12 @@ const bindOrUnbindLinearElementEdge = ( : startOrEnd === "start" || otherEdgeBindableElement.id !== bindableElement.id) ) { - bindLinearElement(linearElement, bindableElement, startOrEnd); + bindLinearElement( + linearElement, + bindableElement, + startOrEnd, + elementsMap, + ); boundToElementIds.add(bindableElement.id); } } else { @@ -140,30 +160,39 @@ const bindOrUnbindLinearElementEdge = ( }; export const bindOrUnbindSelectedElements = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + app: AppClassProperties, ): void => { - elements.forEach((element) => { - if (isBindingElement(element)) { + selectedElements.forEach((selectedElement) => { + if (isBindingElement(selectedElement)) { bindOrUnbindLinearElement( - element, - getElligibleElementForBindingElement(element, "start"), - getElligibleElementForBindingElement(element, "end"), + selectedElement, + getElligibleElementForBindingElement(selectedElement, "start", app), + getElligibleElementForBindingElement(selectedElement, "end", app), + app.scene.getNonDeletedElementsMap(), + ); + } else if (isBindableElement(selectedElement)) { + maybeBindBindableElement( + selectedElement, + app.scene.getNonDeletedElementsMap(), + app, ); - } else if (isBindableElement(element)) { - maybeBindBindableElement(element); } }); }; const maybeBindBindableElement = ( bindableElement: NonDeleted, + elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): void => { - getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( + getElligibleElementsForBindableElementAndWhere(bindableElement, app).forEach( ([linearElement, where]) => bindOrUnbindLinearElement( linearElement, where === "end" ? "keep" : bindableElement, where === "start" ? "keep" : bindableElement, + elementsMap, ), ); }; @@ -171,13 +200,18 @@ const maybeBindBindableElement = ( export const maybeBindLinearElement = ( linearElement: NonDeleted, appState: AppState, - scene: Scene, pointerCoords: { x: number; y: number }, + app: AppClassProperties, ): void => { if (appState.startBoundElement != null) { - bindLinearElement(linearElement, appState.startBoundElement, "start"); + bindLinearElement( + linearElement, + appState.startBoundElement, + "start", + app.scene.getNonDeletedElementsMap(), + ); } - const hoveredElement = getHoveredElementForBinding(pointerCoords, scene); + const hoveredElement = getHoveredElementForBinding(pointerCoords, app); if ( hoveredElement != null && !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( @@ -186,7 +220,12 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement(linearElement, hoveredElement, "end"); + bindLinearElement( + linearElement, + hoveredElement, + "end", + app.scene.getNonDeletedElementsMap(), + ); } }; @@ -194,11 +233,17 @@ export const bindLinearElement = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, ): void => { mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: { elementId: hoveredElement.id, - ...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd), + ...calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), } as PointBinding, }); @@ -239,11 +284,12 @@ export const isLinearElementSimpleAndAlreadyBound = ( }; export const unbindLinearElements = ( - elements: NonDeleted[], + elements: readonly NonDeleted[], + elementsMap: NonDeletedSceneElementsMap, ): void => { elements.forEach((element) => { if (isBindingElement(element)) { - bindOrUnbindLinearElement(element, null, null); + bindOrUnbindLinearElement(element, null, null, elementsMap); } }); }; @@ -266,13 +312,13 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, - scene: Scene, + app: AppClassProperties, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - scene.getNonDeletedElements(), + app.scene.getNonDeletedElements(), (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords), + bindingBorderTest(element, pointerCoords, app), ); return hoveredElement as NonDeleted | null; }; @@ -281,21 +327,33 @@ const calculateFocusAndGap = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, ): { focus: number; gap: number } => { const direction = startOrEnd === "start" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const adjacentPointIndex = edgePointIndex - direction; + const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, edgePointIndex, + elementsMap, ); const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, adjacentPointIndex, + elementsMap, ); return { - focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), - gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), + focus: determineFocusDistance( + hoveredElement, + adjacentPoint, + edgePoint, + elementsMap, + ), + gap: Math.max( + 1, + distanceToBindableElement(hoveredElement, edgePoint, elementsMap), + ), }; }; @@ -306,6 +364,8 @@ const calculateFocusAndGap = ( // in explicitly. export const updateBoundElements = ( changedElement: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; @@ -321,9 +381,9 @@ export const updateBoundElements = ( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); - + const scene = Scene.getScene(changedElement)!; getNonDeletedElements( - Scene.getScene(changedElement)!, + scene, boundLinearElements.map((el) => el.id), ).forEach((element) => { if (!isLinearElement(element)) { @@ -355,16 +415,21 @@ export const updateBoundElements = ( "start", startBinding, changedElement as ExcalidrawBindableElement, + elementsMap, ); updateBoundPoint( element, "end", endBinding, changedElement as ExcalidrawBindableElement, + elementsMap, + ); + const boundText = getBoundTextElement( + element, + scene.getNonDeletedElementsMap(), ); - const boundText = getBoundTextElement(element); if (boundText) { - handleBindTextResize(element, false); + handleBindTextResize(element, scene.getNonDeletedElementsMap(), false); } }); }; @@ -390,6 +455,7 @@ const updateBoundPoint = ( startOrEnd: "start" | "end", binding: PointBinding | null | undefined, changedElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, ): void => { if ( binding == null || @@ -411,11 +477,13 @@ const updateBoundPoint = ( const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, adjacentPointIndex, + elementsMap, ); const focusPointAbsolute = determineFocusPoint( bindingElement, binding.focus, adjacentPoint, + elementsMap, ); let newEdgePoint; // The linear element was not originally pointing inside the bound shape, @@ -428,6 +496,7 @@ const updateBoundPoint = ( adjacentPoint, focusPointAbsolute, binding.gap, + elementsMap, ); if (intersections.length === 0) { // This should never happen, since focusPoint should always be @@ -446,6 +515,7 @@ const updateBoundPoint = ( point: LinearElementEditor.pointFromAbsoluteCoords( linearElement, newEdgePoint, + elementsMap, ), }, ], @@ -476,30 +546,34 @@ const maybeCalculateNewGapWhenScaling = ( // TODO: this is a bottleneck, optimise export const getEligibleElementsForBinding = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + app: AppClassProperties, ): SuggestedBinding[] => { - const includedElementIds = new Set(elements.map(({ id }) => id)); - return elements.flatMap((element) => - isBindingElement(element, false) + const includedElementIds = new Set(selectedElements.map(({ id }) => id)); + return selectedElements.flatMap((selectedElement) => + isBindingElement(selectedElement, false) ? (getElligibleElementsForBindingElement( - element as NonDeleted, + selectedElement as NonDeleted, + app, ).filter( (element) => !includedElementIds.has(element.id), ) as SuggestedBinding[]) - : isBindableElement(element, false) - ? getElligibleElementsForBindableElementAndWhere(element).filter( - (binding) => !includedElementIds.has(binding[0].id), - ) + : isBindableElement(selectedElement, false) + ? getElligibleElementsForBindableElementAndWhere( + selectedElement, + app, + ).filter((binding) => !includedElementIds.has(binding[0].id)) : [], ); }; const getElligibleElementsForBindingElement = ( linearElement: NonDeleted, + app: AppClassProperties, ): NonDeleted[] => { return [ - getElligibleElementForBindingElement(linearElement, "start"), - getElligibleElementForBindingElement(linearElement, "end"), + getElligibleElementForBindingElement(linearElement, "start", app), + getElligibleElementForBindingElement(linearElement, "end", app), ].filter( (element): element is NonDeleted => element != null, @@ -509,27 +583,39 @@ const getElligibleElementsForBindingElement = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", + app: AppClassProperties, ): NonDeleted | null => { return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd), - Scene.getScene(linearElement)!, + getLinearElementEdgeCoors( + linearElement, + startOrEnd, + app.scene.getNonDeletedElementsMap(), + ), + app, ); }; const getLinearElementEdgeCoors = ( linearElement: NonDeleted, startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, ): { x: number; y: number } => { const index = startOrEnd === "start" ? 0 : -1; return tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index), + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + index, + elementsMap, + ), ); }; const getElligibleElementsForBindableElementAndWhere = ( bindableElement: NonDeleted, + app: AppClassProperties, ): SuggestedPointBinding[] => { - return Scene.getScene(bindableElement)! + const scene = Scene.getScene(bindableElement)!; + return scene .getNonDeletedElements() .map((element) => { if (!isBindingElement(element, false)) { @@ -539,11 +625,15 @@ const getElligibleElementsForBindableElementAndWhere = ( element, "start", bindableElement, + scene.getNonDeletedElementsMap(), + app, ); const canBindEnd = isLinearElementEligibleForNewBindingByBindable( element, "end", bindableElement, + scene.getNonDeletedElementsMap(), + app, ); if (!canBindStart && !canBindEnd) { return null; @@ -561,6 +651,8 @@ const isLinearElementEligibleForNewBindingByBindable = ( linearElement: NonDeleted, startOrEnd: "start" | "end", bindableElement: NonDeleted, + elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): boolean => { const existingBinding = linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"]; @@ -573,7 +665,8 @@ const isLinearElementEligibleForNewBindingByBindable = ( ) && bindingBorderTest( bindableElement, - getLinearElementEdgeCoors(linearElement, startOrEnd), + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + app, ) ); }; @@ -745,3 +838,547 @@ const newBoundElementsAfterDeletion = ( } return boundElements.filter((ele) => !deletedElementIds.has(ele.id)); }; + +export const bindingBorderTest = ( + element: NonDeleted, + { x, y }: { x: number; y: number }, + app: AppClassProperties, +): boolean => { + const threshold = maxBindingGap(element, element.width, element.height); + const shape = app.getElementShape(element); + return isPointOnShape([x, y], shape, threshold); +}; + +export const maxBindingGap = ( + element: ExcalidrawElement, + elementWidth: number, + elementHeight: number, +): number => { + // Aligns diamonds with rectangles + const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; + const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); + // We make the bindable boundary bigger for bigger elements + return Math.max(16, Math.min(0.25 * smallerDimension, 32)); +}; + +export const distanceToBindableElement = ( + element: ExcalidrawBindableElement, + point: Point, + elementsMap: ElementsMap, +): number => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return distanceToRectangle(element, point, elementsMap); + case "diamond": + return distanceToDiamond(element, point, elementsMap); + case "ellipse": + return distanceToEllipse(element, point, elementsMap); + } +}; + +const distanceToRectangle = ( + element: + | ExcalidrawRectangleElement + | ExcalidrawTextElement + | ExcalidrawFreeDrawElement + | ExcalidrawImageElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement, + point: Point, + elementsMap: ElementsMap, +): number => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); + return Math.max( + GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), + GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), + ); +}; + +const distanceToDiamond = ( + element: ExcalidrawDiamondElement, + point: Point, + elementsMap: ElementsMap, +): number => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); + const side = GALine.equation(hheight, hwidth, -hheight * hwidth); + return GAPoint.distanceToLine(pointRel, side); +}; + +export const distanceToEllipse = ( + element: ExcalidrawEllipseElement, + point: Point, + elementsMap: ElementsMap, +): number => { + const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); + return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); +}; + +const ellipseParamsForTest = ( + element: ExcalidrawEllipseElement, + point: Point, + elementsMap: ElementsMap, +): [GA.Point, GA.Line] => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); + const [px, py] = GAPoint.toTuple(pointRel); + + // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` + let tx = 0.707; + let ty = 0.707; + + const a = hwidth; + const b = hheight; + + // This is a numerical method to find the params tx, ty at which + // the ellipse has the closest point to the given point + [0, 1, 2, 3].forEach((_) => { + const xx = a * tx; + const yy = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = xx - ex; + const ry = yy - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + }); + + const closestPoint = GA.point(a * tx, b * ty); + + const tangent = GALine.orthogonalThrough(pointRel, closestPoint); + return [pointRel, tangent]; +}; + +// Returns: +// 1. the point relative to the elements (x, y) position +// 2. the point relative to the element's center with positive (x, y) +// 3. half element width +// 4. half element height +// +// Note that for linear elements the (x, y) position is not at the +// top right corner of their boundary. +// +// Rectangles, diamonds and ellipses are symmetrical over axes, +// and other elements have a rectangular boundary, +// so we only need to perform hit tests for the positive quadrant. +const pointRelativeToElement = ( + element: ExcalidrawElement, + pointTuple: Point, + elementsMap: ElementsMap, +): [GA.Point, GA.Point, number, number] => { + const point = GAPoint.from(pointTuple); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const center = coordsCenter(x1, y1, x2, y2); + // GA has angle orientation opposite to `rotate` + const rotate = GATransform.rotation(center, element.angle); + const pointRotated = GATransform.apply(rotate, point); + const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); + const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); + const elementPos = GA.offset(element.x, element.y); + const pointRelToPos = GA.sub(pointRotated, elementPos); + const halfWidth = (x2 - x1) / 2; + const halfHeight = (y2 - y1) / 2; + return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; +}; + +const relativizationToElementCenter = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GA.Transform => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const center = coordsCenter(x1, y1, x2, y2); + // GA has angle orientation opposite to `rotate` + const rotate = GATransform.rotation(center, element.angle); + const translate = GA.reverse( + GATransform.translation(GADirection.from(center)), + ); + return GATransform.compose(rotate, translate); +}; + +const coordsCenter = ( + x1: number, + y1: number, + x2: number, + y2: number, +): GA.Point => { + return GA.point((x1 + x2) / 2, (y1 + y2) / 2); +}; + +// The focus distance is the oriented ratio between the size of +// the `element` and the "focus image" of the element on which +// all focus points lie, so it's a number between -1 and 1. +// The line going through `a` and `b` is a tangent to the "focus image" +// of the element. +export const determineFocusDistance = ( + element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates + a: Point, + // Another point on the line, in absolute coordinates (closer to element) + b: Point, + elementsMap: ElementsMap, +): number => { + const relateToCenter = relativizationToElementCenter(element, elementsMap); + const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); + const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); + const line = GALine.through(aRel, bRel); + const q = element.height / element.width; + const hwidth = element.width / 2; + const hheight = element.height / 2; + const n = line[2]; + const m = line[3]; + const c = line[1]; + const mabs = Math.abs(m); + const nabs = Math.abs(n); + let ret; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + ret = c / (hwidth * (nabs + q * mabs)); + break; + case "diamond": + ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); + break; + case "ellipse": + ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2)); + break; + } + return ret || 0; +}; + +export const determineFocusPoint = ( + element: ExcalidrawBindableElement, + // The oriented, relative distance from the center of `element` of the + // returned focusPoint + focus: number, + adjecentPoint: Point, + elementsMap: ElementsMap, +): Point => { + if (focus === 0) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const center = coordsCenter(x1, y1, x2, y2); + return GAPoint.toTuple(center); + } + const relateToCenter = relativizationToElementCenter(element, elementsMap); + const adjecentPointRel = GATransform.apply( + relateToCenter, + GAPoint.from(adjecentPoint), + ); + const reverseRelateToCenter = GA.reverse(relateToCenter); + let point; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "diamond": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + point = findFocusPointForRectangulars(element, focus, adjecentPointRel); + break; + case "ellipse": + point = findFocusPointForEllipse(element, focus, adjecentPointRel); + break; + } + return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)); +}; + +// Returns 2 or 0 intersection points between line going through `a` and `b` +// and the `element`, in ascending order of distance from `a`. +export const intersectElementWithLine = ( + element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates + a: Point, + // Another point on the line, in absolute coordinates + b: Point, + // If given, the element is inflated by this value + gap: number = 0, + elementsMap: ElementsMap, +): Point[] => { + const relateToCenter = relativizationToElementCenter(element, elementsMap); + const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); + const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); + const line = GALine.through(aRel, bRel); + const reverseRelateToCenter = GA.reverse(relateToCenter); + const intersections = getSortedElementLineIntersections( + element, + line, + aRel, + gap, + ); + return intersections.map((point) => + GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + ); +}; + +const getSortedElementLineIntersections = ( + element: ExcalidrawBindableElement, + // Relative to element center + line: GA.Line, + // Relative to element center + nearPoint: GA.Point, + gap: number = 0, +): GA.Point[] => { + let intersections: GA.Point[]; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "diamond": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + const corners = getCorners(element); + intersections = corners + .flatMap((point, i) => { + const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]]; + return intersectSegment(line, offsetSegment(edge, gap)); + }) + .concat( + corners.flatMap((point) => getCircleIntersections(point, gap, line)), + ); + break; + case "ellipse": + intersections = getEllipseIntersections(element, gap, line); + break; + } + if (intersections.length < 2) { + // Ignore the "edge" case of only intersecting with a single corner + return []; + } + const sortedIntersections = intersections.sort( + (i1, i2) => + GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint), + ); + return [ + sortedIntersections[0], + sortedIntersections[sortedIntersections.length - 1], + ]; +}; + +const getCorners = ( + element: + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawDiamondElement + | ExcalidrawTextElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement, + scale: number = 1, +): GA.Point[] => { + const hx = (scale * element.width) / 2; + const hy = (scale * element.height) / 2; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return [ + GA.point(hx, hy), + GA.point(hx, -hy), + GA.point(-hx, -hy), + GA.point(-hx, hy), + ]; + case "diamond": + return [ + GA.point(0, hy), + GA.point(hx, 0), + GA.point(0, -hy), + GA.point(-hx, 0), + ]; + } +}; + +// Returns intersection of `line` with `segment`, with `segment` moved by +// `gap` in its polar direction. +// If intersection coincides with second segment point returns empty array. +const intersectSegment = ( + line: GA.Line, + segment: [GA.Point, GA.Point], +): GA.Point[] => { + const [a, b] = segment; + const aDist = GAPoint.distanceToLine(a, line); + const bDist = GAPoint.distanceToLine(b, line); + if (aDist * bDist >= 0) { + // The intersection is outside segment `(a, b)` + return []; + } + return [GAPoint.intersect(line, GALine.through(a, b))]; +}; + +const offsetSegment = ( + segment: [GA.Point, GA.Point], + distance: number, +): [GA.Point, GA.Point] => { + const [a, b] = segment; + const offset = GATransform.translationOrthogonal( + GADirection.fromTo(a, b), + distance, + ); + return [GATransform.apply(offset, a), GATransform.apply(offset, b)]; +}; + +const getEllipseIntersections = ( + element: ExcalidrawEllipseElement, + gap: number, + line: GA.Line, +): GA.Point[] => { + const a = element.width / 2 + gap; + const b = element.height / 2 + gap; + const m = line[2]; + const n = line[3]; + const c = line[1]; + const squares = a * a * m * m + b * b * n * n; + const discr = squares - c * c; + if (squares === 0 || discr <= 0) { + return []; + } + const discrRoot = Math.sqrt(discr); + const xn = -a * a * m * c; + const yn = -b * b * n * c; + return [ + GA.point( + (xn + a * b * n * discrRoot) / squares, + (yn - a * b * m * discrRoot) / squares, + ), + GA.point( + (xn - a * b * n * discrRoot) / squares, + (yn + a * b * m * discrRoot) / squares, + ), + ]; +}; + +export const getCircleIntersections = ( + center: GA.Point, + radius: number, + line: GA.Line, +): GA.Point[] => { + if (radius === 0) { + return GAPoint.distanceToLine(line, center) === 0 ? [center] : []; + } + const m = line[2]; + const n = line[3]; + const c = line[1]; + const [a, b] = GAPoint.toTuple(center); + const r = radius; + const squares = m * m + n * n; + const discr = r * r * squares - (m * a + n * b + c) ** 2; + if (squares === 0 || discr <= 0) { + return []; + } + const discrRoot = Math.sqrt(discr); + const xn = a * n * n - b * m * n - m * c; + const yn = b * m * m - a * m * n - n * c; + + return [ + GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares), + GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares), + ]; +}; + +// The focus point is the tangent point of the "focus image" of the +// `element`, where the tangent goes through `point`. +export const findFocusPointForEllipse = ( + ellipse: ExcalidrawEllipseElement, + // Between -1 and 1 (not 0) the relative size of the "focus image" of + // the element on which the focus point lies + relativeDistance: number, + // The point for which we're trying to find the focus point, relative + // to the ellipse center. + point: GA.Point, +): GA.Point => { + const relativeDistanceAbs = Math.abs(relativeDistance); + const a = (ellipse.width * relativeDistanceAbs) / 2; + const b = (ellipse.height * relativeDistanceAbs) / 2; + + const orientation = Math.sign(relativeDistance); + const [px, pyo] = GAPoint.toTuple(point); + + // The calculation below can't handle py = 0 + const py = pyo === 0 ? 0.0001 : pyo; + + const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2; + // Tangent mx + ny + 1 = 0 + const m = + (-px * b ** 2 + + orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / + squares; + + let n = (-m * px - 1) / py; + + if (n === 0) { + // if zero {-0, 0}, fall back to a same-sign value in the similar range + n = (Object.is(n, -0) ? -1 : 1) * 0.01; + } + + const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); + return GA.point(x, (-m * x - 1) / n); +}; + +export const findFocusPointForRectangulars = ( + element: + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawDiamondElement + | ExcalidrawTextElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement, + // Between -1 and 1 for how far away should the focus point be relative + // to the size of the element. Sign determines orientation. + relativeDistance: number, + // The point for which we're trying to find the focus point, relative + // to the element center. + point: GA.Point, +): GA.Point => { + const relativeDistanceAbs = Math.abs(relativeDistance); + const orientation = Math.sign(relativeDistance); + const corners = getCorners(element, relativeDistanceAbs); + + let maxDistance = 0; + let tangentPoint: null | GA.Point = null; + corners.forEach((corner) => { + const distance = orientation * GALine.through(point, corner)[1]; + if (distance > maxDistance) { + maxDistance = distance; + tangentPoint = corner; + } + }); + return tangentPoint!; +}; diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 850c50654..e495343f7 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -1,4 +1,5 @@ import { ROUNDNESS } from "../constants"; +import { arrayToMap } from "../utils"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; @@ -35,35 +36,41 @@ const _ce = ({ describe("getElementAbsoluteCoords", () => { it("test x1 coordinate", () => { - const [x1] = getElementAbsoluteCoords(_ce({ x: 10, y: 0, w: 10, h: 0 })); + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [x1] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(x1).toEqual(10); }); it("test x2 coordinate", () => { - const [, , x2] = getElementAbsoluteCoords( - _ce({ x: 10, y: 0, w: 10, h: 0 }), - ); + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(x2).toEqual(20); }); it("test y1 coordinate", () => { - const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 10, w: 0, h: 10 })); + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(y1).toEqual(10); }); it("test y2 coordinate", () => { - const [, , , y2] = getElementAbsoluteCoords( - _ce({ x: 0, y: 10, w: 0, h: 10 }), - ); + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(y2).toEqual(20); }); }); describe("getElementBounds", () => { it("rectangle", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "rectangle" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "rectangle", + }); + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(39.39339828220179); expect(y1).toEqual(24.393398282201787); expect(x2).toEqual(60.60660171779821); @@ -71,9 +78,17 @@ describe("getElementBounds", () => { }); it("diamond", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "diamond" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "diamond", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + expect(x1).toEqual(42.928932188134524); expect(y1).toEqual(27.928932188134524); expect(x2).toEqual(57.071067811865476); @@ -81,9 +96,16 @@ describe("getElementBounds", () => { }); it("ellipse", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "ellipse" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "ellipse", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(42.09430584957905); expect(y1).toEqual(27.09430584957905); expect(x2).toEqual(57.90569415042095); @@ -91,7 +113,7 @@ describe("getElementBounds", () => { }); it("curved line", () => { - const [x1, y1, x2, y2] = getElementBounds({ + const element = { ..._ce({ t: "line", x: 449.58203125, @@ -105,7 +127,9 @@ describe("getElementBounds", () => { [67.33984375, 92.48828125] as [number, number], [-102.7890625, 52.15625] as [number, number], ], - } as ExcalidrawLinearElement); + } as ExcalidrawLinearElement; + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(360.3176068760539); expect(y1).toEqual(185.90654264413516); expect(x2).toEqual(480.87005902729743); diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index f8d8223f7..6d98087ba 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -5,11 +5,12 @@ import { ExcalidrawFreeDrawElement, NonDeleted, ExcalidrawTextElementWithContainer, + ElementsMap, } from "./types"; import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; import { Drawable, Op } from "roughjs/bin/core"; -import { Point } from "../types"; +import { AppState, Point } from "../types"; import { generateRoughOptions } from "../scene/Shape"; import { isArrowElement, @@ -23,7 +24,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; -import Scene from "../scene/Scene"; +import { arrayToMap } from "../utils"; export type RectangleBox = { x: number; @@ -35,7 +36,9 @@ export type RectangleBox = { type MaybeQuadraticSolution = [number | null, number | null] | false; -// x and y position of top left corner, x and y position of bottom right corner +/** + * x and y position of top left corner, x and y position of bottom right corner + */ export type Bounds = readonly [ minX: number, minY: number, @@ -43,6 +46,13 @@ export type Bounds = readonly [ maxY: number, ]; +export type SceneBounds = readonly [ + sceneX: number, + sceneY: number, + sceneX2: number, + sceneY2: number, +]; + export class ElementBounds { private static boundsCache = new WeakMap< ExcalidrawElement, @@ -52,7 +62,7 @@ export class ElementBounds { } >(); - static getBounds(element: ExcalidrawElement) { + static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { const cachedBounds = ElementBounds.boundsCache.get(element); if ( @@ -64,29 +74,26 @@ export class ElementBounds { ) { return cachedBounds.bounds; } + const bounds = ElementBounds.calculateBounds(element, elementsMap); - const bounds = ElementBounds.calculateBounds(element); - - // hack to ensure that downstream checks could retrieve element Scene - // so as to have correctly calculated bounds - // FIXME remove when we get rid of all the id:Scene / element:Scene mapping - const shouldCache = Scene.getScene(element); - - if (shouldCache) { - ElementBounds.boundsCache.set(element, { - version: element.version, - bounds, - }); - } + ElementBounds.boundsCache.set(element, { + version: element.version, + bounds, + }); return bounds; } - private static calculateBounds(element: ExcalidrawElement): Bounds { + private static calculateBounds( + element: ExcalidrawElement, + elementsMap: ElementsMap, + ): Bounds { let bounds: Bounds; - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); - + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); if (isFreeDrawElement(element)) { const [minX, minY, maxX, maxY] = getBoundsFromPoints( element.points.map(([x, y]) => @@ -101,7 +108,7 @@ export class ElementBounds { maxY + element.y, ]; } else if (isLinearElement(element)) { - bounds = getLinearElementRotatedBounds(element, cx, cy); + bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); } else if (element.type === "diamond") { const [x11, y11] = rotate(cx, y1, cx, cy, element.angle); const [x12, y12] = rotate(cx, y2, cx, cy, element.angle); @@ -142,6 +149,7 @@ export class ElementBounds { // This set of functions retrieves the absolute position of the 4 points. export const getElementAbsoluteCoords = ( element: ExcalidrawElement, + elementsMap: ElementsMap, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { if (isFreeDrawElement(element)) { @@ -149,14 +157,18 @@ export const getElementAbsoluteCoords = ( } else if (isLinearElement(element)) { return LinearElementEditor.getElementAbsoluteCoords( element, + elementsMap, includeBoundText, ); } else if (isTextElement(element)) { - const container = getContainerElement(element); + const container = elementsMap + ? getContainerElement(element, elementsMap) + : null; if (isArrowElement(container)) { const coords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); return [ coords.x, @@ -185,8 +197,12 @@ export const getElementAbsoluteCoords = ( */ export const getElementLineSegments = ( element: ExcalidrawElement, + elementsMap: ElementsMap, ): [Point, Point][] => { - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); const center: Point = [cx, cy]; @@ -283,13 +299,6 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => { ]; }; -export const pointRelativeTo = ( - element: ExcalidrawElement, - absoluteCoords: Point, -): Point => { - return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; -}; - export const getDiamondPoints = (element: ExcalidrawElement) => { // Here we add +1 to avoid these numbers to be 0 // otherwise rough.js will throw an error complaining about it @@ -663,7 +672,10 @@ const getLinearElementRotatedBounds = ( element: ExcalidrawLinearElement, cx: number, cy: number, + elementsMap: ElementsMap, ): Bounds => { + const boundTextElement = getBoundTextElement(element, elementsMap); + if (element.points.length < 2) { const [pointX, pointY] = element.points[0]; const [x, y] = rotate( @@ -675,10 +687,10 @@ const getLinearElementRotatedBounds = ( ); let coords: Bounds = [x, y, x, y]; - const boundTextElement = getBoundTextElement(element); if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, [x, y, x, y], boundTextElement, ); @@ -700,10 +712,10 @@ const getLinearElementRotatedBounds = ( rotate(element.x + x, element.y + y, cx, cy, element.angle); const res = getMinMaxXYFromCurvePathOps(ops, transformXY); let coords: Bounds = [res[0], res[1], res[2], res[3]]; - const boundTextElement = getBoundTextElement(element); if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, coords, boundTextElement, ); @@ -717,9 +729,13 @@ const getLinearElementRotatedBounds = ( return coords; }; -export const getElementBounds = (element: ExcalidrawElement): Bounds => { - return ElementBounds.getBounds(element); +export const getElementBounds = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): Bounds => { + return ElementBounds.getBounds(element, elementsMap); }; + export const getCommonBounds = ( elements: readonly ExcalidrawElement[], ): Bounds => { @@ -732,8 +748,10 @@ export const getCommonBounds = ( let minY = Infinity; let maxY = -Infinity; + const elementsMap = arrayToMap(elements); + elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element); + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); minX = Math.min(minX, x1); minY = Math.min(minY, y1); maxX = Math.max(maxX, x2); @@ -839,9 +857,9 @@ export const getClosestElementBounds = ( let minDistance = Infinity; let closestElement = elements[0]; - + const elementsMap = arrayToMap(elements); elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element); + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y); if (distance < minDistance) { @@ -850,7 +868,7 @@ export const getClosestElementBounds = ( } }); - return getElementBounds(closestElement); + return getElementBounds(closestElement, elementsMap); }; export interface BoundingBox { @@ -879,3 +897,21 @@ export const getCommonBoundingBox = ( midY: (minY + maxY) / 2, }; }; + +/** + * returns scene coords of user's editor viewport (visible canvas area) bounds + */ +export const getVisibleSceneBounds = ({ + scrollX, + scrollY, + width, + height, + zoom, +}: AppState): SceneBounds => { + return [ + -scrollX, + -scrollY, + -scrollX + width / zoom.value, + -scrollY + height / zoom.value, + ]; +}; diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 709781b22..51769a8b0 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -1,1116 +1,113 @@ -import * as GA from "../ga"; -import * as GAPoint from "../gapoints"; -import * as GADirection from "../gadirections"; -import * as GALine from "../galines"; -import * as GATransform from "../gatransforms"; +import { isPathALoop, isPointWithinBounds } from "../math"; import { - distance2d, - rotatePoint, - isPathALoop, - isPointInPolygon, - rotate, -} from "../math"; -import { pointsOnBezierCurves } from "points-on-curve"; - -import { - NonDeletedExcalidrawElement, - ExcalidrawBindableElement, + ElementsMap, ExcalidrawElement, ExcalidrawRectangleElement, - ExcalidrawDiamondElement, - ExcalidrawTextElement, - ExcalidrawEllipseElement, - NonDeleted, - ExcalidrawFreeDrawElement, - ExcalidrawImageElement, - ExcalidrawLinearElement, - StrokeRoundness, - ExcalidrawFrameLikeElement, - ExcalidrawIframeLikeElement, } from "./types"; +import { getElementBounds } from "./bounds"; +import { FrameNameBounds } from "../types"; import { - getElementAbsoluteCoords, - getCurvePathOps, - getRectangleBoxAbsoluteCoords, - RectangleBox, -} from "./bounds"; -import { FrameNameBoundsCache, Point } from "../types"; -import { Drawable } from "roughjs/bin/core"; -import { AppState } from "../types"; + Polygon, + GeometricShape, + getPolygonShape, +} from "../../utils/geometry/shape"; +import { isPointInShape, isPointOnShape } from "../../utils/collision"; +import { isTransparent } from "../utils"; import { hasBoundTextElement, - isFrameLikeElement, isIframeLikeElement, isImageElement, + isTextElement, } from "./typeChecks"; -import { isTextElement } from "."; -import { isTransparent } from "../utils"; -import { shouldShowBoundingBox } from "./transformHandles"; -import { getBoundTextElement } from "./textElement"; -import { Mutable } from "../utility-types"; -import { ShapeCache } from "../scene/ShapeCache"; -const isElementDraggableFromInside = ( - element: NonDeletedExcalidrawElement, -): boolean => { +export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { return false; } - if (element.type === "freedraw") { - return true; - } const isDraggableFromInside = !isTransparent(element.backgroundColor) || hasBoundTextElement(element) || - isIframeLikeElement(element); + isIframeLikeElement(element) || + isTextElement(element); + if (element.type === "line") { return isDraggableFromInside && isPathALoop(element.points); } + + if (element.type === "freedraw") { + return isDraggableFromInside && isPathALoop(element.points); + } + return isDraggableFromInside || isImageElement(element); }; -export const hitTest = ( - element: NonDeletedExcalidrawElement, - appState: AppState, - frameNameBoundsCache: FrameNameBoundsCache, - x: number, - y: number, -): boolean => { - // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / appState.zoom.value; - const point: Point = [x, y]; - - if ( - isElementSelected(appState, element) && - shouldShowBoundingBox([element], appState) - ) { - return isPointHittingElementBoundingBox( - element, - point, - threshold, - frameNameBoundsCache, - ); - } - - const boundTextElement = getBoundTextElement(element); - if (boundTextElement) { - const isHittingBoundTextElement = hitTest( - boundTextElement, - appState, - frameNameBoundsCache, - x, - y, - ); - if (isHittingBoundTextElement) { - return true; - } - } - return isHittingElementNotConsideringBoundingBox( - element, - appState, - frameNameBoundsCache, - point, - ); +export type HitTestArgs = { + x: number; + y: number; + element: ExcalidrawElement; + shape: GeometricShape; + threshold?: number; + frameNameBound?: FrameNameBounds | null; }; -export const isHittingElementBoundingBoxWithoutHittingElement = ( - element: NonDeletedExcalidrawElement, - appState: AppState, - frameNameBoundsCache: FrameNameBoundsCache, - x: number, - y: number, -): boolean => { - const threshold = 10 / appState.zoom.value; +export const hitElementItself = ({ + x, + y, + element, + shape, + threshold = 10, + frameNameBound = null, +}: HitTestArgs) => { + let hit = shouldTestInside(element) + ? // Since `inShape` tests STRICTLY againt the insides of a shape + // we would need `onShape` as well to include the "borders" + isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold) + : isPointOnShape([x, y], shape, threshold); - // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element - // eg for linear elements text can be outside the element bounding box - const boundTextElement = getBoundTextElement(element); - if ( - boundTextElement && - hitTest(boundTextElement, appState, frameNameBoundsCache, x, y) - ) { - return false; - } - - return ( - !isHittingElementNotConsideringBoundingBox( - element, - appState, - frameNameBoundsCache, - [x, y], - ) && - isPointHittingElementBoundingBox( - element, - [x, y], - threshold, - frameNameBoundsCache, - ) - ); -}; - -export const isHittingElementNotConsideringBoundingBox = ( - element: NonDeletedExcalidrawElement, - appState: AppState, - frameNameBoundsCache: FrameNameBoundsCache | null, - point: Point, -): boolean => { - const threshold = 10 / appState.zoom.value; - const check = isTextElement(element) - ? isStrictlyInside - : isElementDraggableFromInside(element) - ? isInsideCheck - : isNearCheck; - return hitTestPointAgainstElement({ - element, - point, - threshold, - check, - frameNameBoundsCache, - }); -}; - -const isElementSelected = ( - appState: AppState, - element: NonDeleted, -) => appState.selectedElementIds[element.id]; - -export const isPointHittingElementBoundingBox = ( - element: NonDeleted, - [x, y]: Point, - threshold: number, - frameNameBoundsCache: FrameNameBoundsCache | null, -) => { - // frames needs be checked differently so as to be able to drag it - // by its frame, whether it has been selected or not - // this logic here is not ideal - // TODO: refactor it later... - if (isFrameLikeElement(element)) { - return hitTestPointAgainstElement({ - element, - point: [x, y], - threshold, - check: isInsideCheck, - frameNameBoundsCache, + // hit test against a frame's name + if (!hit && frameNameBound) { + hit = isPointInShape([x, y], { + type: "polygon", + data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) + .data as Polygon, }); } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const elementCenterX = (x1 + x2) / 2; - const elementCenterY = (y1 + y2) / 2; - // reverse rotate to take element's angle into account. - const [rotatedX, rotatedY] = rotate( - x, - y, - elementCenterX, - elementCenterY, - -element.angle, - ); + return hit; +}; +export const hitElementBoundingBox = ( + x: number, + y: number, + element: ExcalidrawElement, + elementsMap: ElementsMap, + tolerance = 0, +) => { + let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); + x1 -= tolerance; + y1 -= tolerance; + x2 += tolerance; + y2 += tolerance; + return isPointWithinBounds([x1, y1], [x, y], [x2, y2]); +}; + +export const hitElementBoundingBoxOnly = ( + hitArgs: HitTestArgs, + elementsMap: ElementsMap, +) => { return ( - rotatedX > x1 - threshold && - rotatedX < x2 + threshold && - rotatedY > y1 - threshold && - rotatedY < y2 + threshold + !hitElementItself(hitArgs) && + hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap) ); }; -export const bindingBorderTest = ( - element: NonDeleted, - { x, y }: { x: number; y: number }, -): boolean => { - const threshold = maxBindingGap(element, element.width, element.height); - const check = isOutsideCheck; - const point: Point = [x, y]; - return hitTestPointAgainstElement({ - element, - point, - threshold, - check, - frameNameBoundsCache: null, - }); -}; - -export const maxBindingGap = ( - element: ExcalidrawElement, - elementWidth: number, - elementHeight: number, -): number => { - // Aligns diamonds with rectangles - const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; - const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); - // We make the bindable boundary bigger for bigger elements - return Math.max(16, Math.min(0.25 * smallerDimension, 32)); -}; - -type HitTestArgs = { - element: NonDeletedExcalidrawElement; - point: Point; - threshold: number; - check: (distance: number, threshold: number) => boolean; - frameNameBoundsCache: FrameNameBoundsCache | null; -}; - -const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { - switch (args.element.type) { - case "rectangle": - case "iframe": - case "embeddable": - case "image": - case "text": - case "diamond": - case "ellipse": - const distance = distanceToBindableElement(args.element, args.point); - return args.check(distance, args.threshold); - case "freedraw": { - if ( - !args.check( - distanceToRectangle(args.element, args.point), - args.threshold, - ) - ) { - return false; - } - - return hitTestFreeDrawElement(args.element, args.point, args.threshold); - } - case "arrow": - case "line": - return hitTestLinear(args); - case "selection": - console.warn( - "This should not happen, we need to investigate why it does.", - ); - return false; - case "frame": - case "magicframe": { - // check distance to frame element first - if ( - args.check( - distanceToBindableElement(args.element, args.point), - args.threshold, - ) - ) { - return true; - } - - const frameNameBounds = args.frameNameBoundsCache?.get(args.element); - - if (frameNameBounds) { - return args.check( - distanceToRectangleBox(frameNameBounds, args.point), - args.threshold, - ); - } - return false; - } - } -}; - -export const distanceToBindableElement = ( - element: ExcalidrawBindableElement, - point: Point, -): number => { - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - return distanceToRectangle(element, point); - case "diamond": - return distanceToDiamond(element, point); - case "ellipse": - return distanceToEllipse(element, point); - } -}; - -const isStrictlyInside = (distance: number, threshold: number): boolean => { - return distance < 0; -}; - -const isInsideCheck = (distance: number, threshold: number): boolean => { - return distance < threshold; -}; - -const isNearCheck = (distance: number, threshold: number): boolean => { - return Math.abs(distance) < threshold; -}; - -const isOutsideCheck = (distance: number, threshold: number): boolean => { - return 0 <= distance && distance < threshold; -}; - -const distanceToRectangle = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawTextElement - | ExcalidrawFreeDrawElement - | ExcalidrawImageElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - point: Point, -): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); - return Math.max( - GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), - GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), - ); -}; - -const distanceToRectangleBox = (box: RectangleBox, point: Point): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box); - return Math.max( - GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), - GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), - ); -}; - -const distanceToDiamond = ( - element: ExcalidrawDiamondElement, - point: Point, -): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); - const side = GALine.equation(hheight, hwidth, -hheight * hwidth); - return GAPoint.distanceToLine(pointRel, side); -}; - -const distanceToEllipse = ( - element: ExcalidrawEllipseElement, - point: Point, -): number => { - const [pointRel, tangent] = ellipseParamsForTest(element, point); - return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); -}; - -const ellipseParamsForTest = ( - element: ExcalidrawEllipseElement, - point: Point, -): [GA.Point, GA.Line] => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); - const [px, py] = GAPoint.toTuple(pointRel); - - // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` - let tx = 0.707; - let ty = 0.707; - - const a = hwidth; - const b = hheight; - - // This is a numerical method to find the params tx, ty at which - // the ellipse has the closest point to the given point - [0, 1, 2, 3].forEach((_) => { - const xx = a * tx; - const yy = b * ty; - - const ex = ((a * a - b * b) * tx ** 3) / a; - const ey = ((b * b - a * a) * ty ** 3) / b; - - const rx = xx - ex; - const ry = yy - ey; - - const qx = px - ex; - const qy = py - ey; - - const r = Math.hypot(ry, rx); - const q = Math.hypot(qy, qx); - - tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); - ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); - const t = Math.hypot(ty, tx); - tx /= t; - ty /= t; - }); - - const closestPoint = GA.point(a * tx, b * ty); - - const tangent = GALine.orthogonalThrough(pointRel, closestPoint); - return [pointRel, tangent]; -}; - -const hitTestFreeDrawElement = ( - element: ExcalidrawFreeDrawElement, - point: Point, - threshold: number, -): boolean => { - // Check point-distance-to-line-segment for every segment in the - // element's points (its input points, not its outline points). - // This is... okay? It's plenty fast, but the GA library may - // have a faster option. - - let x: number; - let y: number; - - if (element.angle === 0) { - x = point[0] - element.x; - y = point[1] - element.y; - } else { - // Counter-rotate the point around center before testing - const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element); - const rotatedPoint = rotatePoint( - point, - [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2], - -element.angle, - ); - x = rotatedPoint[0] - element.x; - y = rotatedPoint[1] - element.y; - } - - let [A, B] = element.points; - let P: readonly [number, number]; - - // For freedraw dots - if ( - distance2d(A[0], A[1], x, y) < threshold || - distance2d(B[0], B[1], x, y) < threshold - ) { - return true; - } - - // For freedraw lines - for (let i = 0; i < element.points.length; i++) { - const delta = [B[0] - A[0], B[1] - A[1]]; - const length = Math.hypot(delta[1], delta[0]); - - const U = [delta[0] / length, delta[1] / length]; - const C = [x - A[0], y - A[1]]; - const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]); - P = [A[0] + U[0] * d, A[1] + U[1] * d]; - - const da = distance2d(P[0], P[1], A[0], A[1]); - const db = distance2d(P[0], P[1], B[0], B[1]); - - P = db < da && da > length ? B : da < db && db > length ? A : P; - - if (Math.hypot(y - P[1], x - P[0]) < threshold) { - return true; - } - - A = B; - B = element.points[i + 1]; - } - - const shape = ShapeCache.get(element); - - // for filled freedraw shapes, support - // selecting from inside - if (shape && shape.sets.length) { - return element.fillStyle === "solid" - ? hitTestCurveInside(shape, x, y, "round") - : hitTestRoughShape(shape, x, y, threshold); - } - - return false; -}; - -const hitTestLinear = (args: HitTestArgs): boolean => { - const { element, threshold } = args; - if (!ShapeCache.get(element)) { - return false; - } - - const [point, pointAbs, hwidth, hheight] = pointRelativeToElement( - args.element, - args.point, - ); - const side1 = GALine.equation(0, 1, -hheight); - const side2 = GALine.equation(1, 0, -hwidth); - if ( - !isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) || - !isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold) - ) { - return false; - } - const [relX, relY] = GAPoint.toTuple(point); - - const shape = ShapeCache.get(element as ExcalidrawLinearElement); - - if (!shape) { - return false; - } - - if (args.check === isInsideCheck) { - const hit = shape.some((subshape) => - hitTestCurveInside( - subshape, - relX, - relY, - element.roundness ? "round" : "sharp", - ), - ); - if (hit) { - return true; - } - } - - // hit test all "subshapes" of the linear element - return shape.some((subshape) => - hitTestRoughShape(subshape, relX, relY, threshold), - ); -}; - -// Returns: -// 1. the point relative to the elements (x, y) position -// 2. the point relative to the element's center with positive (x, y) -// 3. half element width -// 4. half element height -// -// Note that for linear elements the (x, y) position is not at the -// top right corner of their boundary. -// -// Rectangles, diamonds and ellipses are symmetrical over axes, -// and other elements have a rectangular boundary, -// so we only need to perform hit tests for the positive quadrant. -const pointRelativeToElement = ( - element: ExcalidrawElement, - pointTuple: Point, -): [GA.Point, GA.Point, number, number] => { - const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const center = coordsCenter(x1, y1, x2, y2); - // GA has angle orientation opposite to `rotate` - const rotate = GATransform.rotation(center, element.angle); - const pointRotated = GATransform.apply(rotate, point); - const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); - const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); - const elementPos = GA.offset(element.x, element.y); - const pointRelToPos = GA.sub(pointRotated, elementPos); - const halfWidth = (x2 - x1) / 2; - const halfHeight = (y2 - y1) / 2; - return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; -}; - -const pointRelativeToDivElement = ( - pointTuple: Point, - rectangle: RectangleBox, -): [GA.Point, GA.Point, number, number] => { - const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle); - const center = coordsCenter(x1, y1, x2, y2); - const rotate = GATransform.rotation(center, rectangle.angle); - const pointRotated = GATransform.apply(rotate, point); - const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); - const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); - const elementPos = GA.offset(rectangle.x, rectangle.y); - const pointRelToPos = GA.sub(pointRotated, elementPos); - const halfWidth = (x2 - x1) / 2; - const halfHeight = (y2 - y1) / 2; - return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; -}; - -// Returns point in absolute coordinates -export const pointInAbsoluteCoords = ( - element: ExcalidrawElement, - // Point relative to the element position - point: Point, -): Point => { - const [x, y] = point; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const cx = (x2 - x1) / 2; - const cy = (y2 - y1) / 2; - const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle); - return [element.x + rotatedX, element.y + rotatedY]; -}; - -const relativizationToElementCenter = ( - element: ExcalidrawElement, -): GA.Transform => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const center = coordsCenter(x1, y1, x2, y2); - // GA has angle orientation opposite to `rotate` - const rotate = GATransform.rotation(center, element.angle); - const translate = GA.reverse( - GATransform.translation(GADirection.from(center)), - ); - return GATransform.compose(rotate, translate); -}; - -const coordsCenter = ( - x1: number, - y1: number, - x2: number, - y2: number, -): GA.Point => { - return GA.point((x1 + x2) / 2, (y1 + y2) / 2); -}; - -// The focus distance is the oriented ratio between the size of -// the `element` and the "focus image" of the element on which -// all focus points lie, so it's a number between -1 and 1. -// The line going through `a` and `b` is a tangent to the "focus image" -// of the element. -export const determineFocusDistance = ( - element: ExcalidrawBindableElement, - // Point on the line, in absolute coordinates - a: Point, - // Another point on the line, in absolute coordinates (closer to element) - b: Point, -): number => { - const relateToCenter = relativizationToElementCenter(element); - const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); - const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); - const line = GALine.through(aRel, bRel); - const q = element.height / element.width; - const hwidth = element.width / 2; - const hheight = element.height / 2; - const n = line[2]; - const m = line[3]; - const c = line[1]; - const mabs = Math.abs(m); - const nabs = Math.abs(n); - let ret; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - ret = c / (hwidth * (nabs + q * mabs)); - break; - case "diamond": - ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); - break; - case "ellipse": - ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2)); - break; - } - return ret || 0; -}; - -export const determineFocusPoint = ( - element: ExcalidrawBindableElement, - // The oriented, relative distance from the center of `element` of the - // returned focusPoint - focus: number, - adjecentPoint: Point, -): Point => { - if (focus === 0) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const center = coordsCenter(x1, y1, x2, y2); - return GAPoint.toTuple(center); - } - const relateToCenter = relativizationToElementCenter(element); - const adjecentPointRel = GATransform.apply( - relateToCenter, - GAPoint.from(adjecentPoint), - ); - const reverseRelateToCenter = GA.reverse(relateToCenter); - let point; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - point = findFocusPointForRectangulars(element, focus, adjecentPointRel); - break; - case "ellipse": - point = findFocusPointForEllipse(element, focus, adjecentPointRel); - break; - } - return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)); -}; - -// Returns 2 or 0 intersection points between line going through `a` and `b` -// and the `element`, in ascending order of distance from `a`. -export const intersectElementWithLine = ( - element: ExcalidrawBindableElement, - // Point on the line, in absolute coordinates - a: Point, - // Another point on the line, in absolute coordinates - b: Point, - // If given, the element is inflated by this value - gap: number = 0, -): Point[] => { - const relateToCenter = relativizationToElementCenter(element); - const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); - const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); - const line = GALine.through(aRel, bRel); - const reverseRelateToCenter = GA.reverse(relateToCenter); - const intersections = getSortedElementLineIntersections( - element, - line, - aRel, - gap, - ); - return intersections.map((point) => - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), - ); -}; - -const getSortedElementLineIntersections = ( - element: ExcalidrawBindableElement, - // Relative to element center - line: GA.Line, - // Relative to element center - nearPoint: GA.Point, - gap: number = 0, -): GA.Point[] => { - let intersections: GA.Point[]; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - const corners = getCorners(element); - intersections = corners - .flatMap((point, i) => { - const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]]; - return intersectSegment(line, offsetSegment(edge, gap)); - }) - .concat( - corners.flatMap((point) => getCircleIntersections(point, gap, line)), - ); - break; - case "ellipse": - intersections = getEllipseIntersections(element, gap, line); - break; - } - if (intersections.length < 2) { - // Ignore the "edge" case of only intersecting with a single corner - return []; - } - const sortedIntersections = intersections.sort( - (i1, i2) => - GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint), - ); - return [ - sortedIntersections[0], - sortedIntersections[sortedIntersections.length - 1], - ]; -}; - -const getCorners = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawImageElement - | ExcalidrawDiamondElement - | ExcalidrawTextElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - scale: number = 1, -): GA.Point[] => { - const hx = (scale * element.width) / 2; - const hy = (scale * element.height) / 2; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - return [ - GA.point(hx, hy), - GA.point(hx, -hy), - GA.point(-hx, -hy), - GA.point(-hx, hy), - ]; - case "diamond": - return [ - GA.point(0, hy), - GA.point(hx, 0), - GA.point(0, -hy), - GA.point(-hx, 0), - ]; - } -}; - -// Returns intersection of `line` with `segment`, with `segment` moved by -// `gap` in its polar direction. -// If intersection coincides with second segment point returns empty array. -const intersectSegment = ( - line: GA.Line, - segment: [GA.Point, GA.Point], -): GA.Point[] => { - const [a, b] = segment; - const aDist = GAPoint.distanceToLine(a, line); - const bDist = GAPoint.distanceToLine(b, line); - if (aDist * bDist >= 0) { - // The intersection is outside segment `(a, b)` - return []; - } - return [GAPoint.intersect(line, GALine.through(a, b))]; -}; - -const offsetSegment = ( - segment: [GA.Point, GA.Point], - distance: number, -): [GA.Point, GA.Point] => { - const [a, b] = segment; - const offset = GATransform.translationOrthogonal( - GADirection.fromTo(a, b), - distance, - ); - return [GATransform.apply(offset, a), GATransform.apply(offset, b)]; -}; - -const getEllipseIntersections = ( - element: ExcalidrawEllipseElement, - gap: number, - line: GA.Line, -): GA.Point[] => { - const a = element.width / 2 + gap; - const b = element.height / 2 + gap; - const m = line[2]; - const n = line[3]; - const c = line[1]; - const squares = a * a * m * m + b * b * n * n; - const discr = squares - c * c; - if (squares === 0 || discr <= 0) { - return []; - } - const discrRoot = Math.sqrt(discr); - const xn = -a * a * m * c; - const yn = -b * b * n * c; - return [ - GA.point( - (xn + a * b * n * discrRoot) / squares, - (yn - a * b * m * discrRoot) / squares, - ), - GA.point( - (xn - a * b * n * discrRoot) / squares, - (yn + a * b * m * discrRoot) / squares, - ), - ]; -}; - -export const getCircleIntersections = ( - center: GA.Point, - radius: number, - line: GA.Line, -): GA.Point[] => { - if (radius === 0) { - return GAPoint.distanceToLine(line, center) === 0 ? [center] : []; - } - const m = line[2]; - const n = line[3]; - const c = line[1]; - const [a, b] = GAPoint.toTuple(center); - const r = radius; - const squares = m * m + n * n; - const discr = r * r * squares - (m * a + n * b + c) ** 2; - if (squares === 0 || discr <= 0) { - return []; - } - const discrRoot = Math.sqrt(discr); - const xn = a * n * n - b * m * n - m * c; - const yn = b * m * m - a * m * n - n * c; - - return [ - GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares), - GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares), - ]; -}; - -// The focus point is the tangent point of the "focus image" of the -// `element`, where the tangent goes through `point`. -export const findFocusPointForEllipse = ( - ellipse: ExcalidrawEllipseElement, - // Between -1 and 1 (not 0) the relative size of the "focus image" of - // the element on which the focus point lies - relativeDistance: number, - // The point for which we're trying to find the focus point, relative - // to the ellipse center. - point: GA.Point, -): GA.Point => { - const relativeDistanceAbs = Math.abs(relativeDistance); - const a = (ellipse.width * relativeDistanceAbs) / 2; - const b = (ellipse.height * relativeDistanceAbs) / 2; - - const orientation = Math.sign(relativeDistance); - const [px, pyo] = GAPoint.toTuple(point); - - // The calculation below can't handle py = 0 - const py = pyo === 0 ? 0.0001 : pyo; - - const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2; - // Tangent mx + ny + 1 = 0 - const m = - (-px * b ** 2 + - orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / - squares; - - let n = (-m * px - 1) / py; - - if (n === 0) { - // if zero {-0, 0}, fall back to a same-sign value in the similar range - n = (Object.is(n, -0) ? -1 : 1) * 0.01; - } - - const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); - return GA.point(x, (-m * x - 1) / n); -}; - -export const findFocusPointForRectangulars = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawImageElement - | ExcalidrawDiamondElement - | ExcalidrawTextElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - // Between -1 and 1 for how far away should the focus point be relative - // to the size of the element. Sign determines orientation. - relativeDistance: number, - // The point for which we're trying to find the focus point, relative - // to the element center. - point: GA.Point, -): GA.Point => { - const relativeDistanceAbs = Math.abs(relativeDistance); - const orientation = Math.sign(relativeDistance); - const corners = getCorners(element, relativeDistanceAbs); - - let maxDistance = 0; - let tangentPoint: null | GA.Point = null; - corners.forEach((corner) => { - const distance = orientation * GALine.through(point, corner)[1]; - if (distance > maxDistance) { - maxDistance = distance; - tangentPoint = corner; - } - }); - return tangentPoint!; -}; - -const pointInBezierEquation = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, - [mx, my]: Point, - lineThreshold: number, -) => { - // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 - const equation = (t: number, idx: number) => - Math.pow(1 - t, 3) * p3[idx] + - 3 * t * Math.pow(1 - t, 2) * p2[idx] + - 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + - p0[idx] * Math.pow(t, 3); - - // go through t in increments of 0.01 - let t = 0; - while (t <= 1.0) { - const tx = equation(t, 0); - const ty = equation(t, 1); - - const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2)); - - if (diff < lineThreshold) { - return true; - } - - t += 0.01; - } - - return false; -}; - -const hitTestCurveInside = ( - drawable: Drawable, +export const hitElementBoundText = ( x: number, y: number, - roundness: StrokeRoundness, + textShape: GeometricShape | null, ) => { - const ops = getCurvePathOps(drawable); - const points: Mutable[] = []; - let odd = false; // select one line out of double lines - for (const operation of ops) { - if (operation.op === "move") { - odd = !odd; - if (odd) { - points.push([operation.data[0], operation.data[1]]); - } - } else if (operation.op === "bcurveTo") { - if (odd) { - points.push([operation.data[0], operation.data[1]]); - points.push([operation.data[2], operation.data[3]]); - points.push([operation.data[4], operation.data[5]]); - } - } else if (operation.op === "lineTo") { - if (odd) { - points.push([operation.data[0], operation.data[1]]); - } - } - } - if (points.length >= 4) { - if (roundness === "sharp") { - return isPointInPolygon(points, x, y); - } - const polygonPoints = pointsOnBezierCurves(points, 10, 5); - return isPointInPolygon(polygonPoints, x, y); - } - return false; -}; - -const hitTestRoughShape = ( - drawable: Drawable, - x: number, - y: number, - lineThreshold: number, -) => { - // read operations from first opSet - const ops = getCurvePathOps(drawable); - - // set start position as (0,0) just in case - // move operation does not exist (unlikely but it is worth safekeeping it) - let currentP: Point = [0, 0]; - - return ops.some(({ op, data }, idx) => { - // There are only four operation types: - // move, bcurveTo, lineTo, and curveTo - if (op === "move") { - // change starting point - currentP = data as unknown as Point; - // move operation does not draw anything; so, it always - // returns false - } else if (op === "bcurveTo") { - // create points from bezier curve - // bezier curve stores data as a flattened array of three positions - // [x1, y1, x2, y2, x3, y3] - const p1 = [data[0], data[1]] as Point; - const p2 = [data[2], data[3]] as Point; - const p3 = [data[4], data[5]] as Point; - - const p0 = currentP; - currentP = p3; - - // check if points are on the curve - // cubic bezier curves require four parameters - // the first parameter is the last stored position (p0) - const retVal = pointInBezierEquation( - p0, - p1, - p2, - p3, - [x, y], - lineThreshold, - ); - - // set end point of bezier curve as the new starting point for - // upcoming operations as each operation is based on the last drawn - // position of the previous operation - return retVal; - } else if (op === "lineTo") { - return hitTestCurveInside(drawable, x, y, "sharp"); - } else if (op === "qcurveTo") { - // TODO: Implement this - console.warn("qcurveTo is not implemented yet"); - } - - return false; - }); + return textShape && isPointInShape([x, y], textShape); }; diff --git a/packages/excalidraw/element/containerCache.ts b/packages/excalidraw/element/containerCache.ts new file mode 100644 index 000000000..c744f6c8e --- /dev/null +++ b/packages/excalidraw/element/containerCache.ts @@ -0,0 +1,33 @@ +import { ExcalidrawTextContainer } from "./types"; + +export const originalContainerCache: { + [id: ExcalidrawTextContainer["id"]]: + | { + height: ExcalidrawTextContainer["height"]; + } + | undefined; +} = {}; + +export const updateOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], + height: ExcalidrawTextContainer["height"], +) => { + const data = + originalContainerCache[id] || (originalContainerCache[id] = { height }); + data.height = height; + return data; +}; + +export const resetOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], +) => { + if (originalContainerCache[id]) { + delete originalContainerCache[id]; + } +}; + +export const getOriginalContainerHeightFromCache = ( + id: ExcalidrawTextContainer["id"], +) => { + return originalContainerCache[id]?.height ?? null; +}; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index c91ad64c6..5121f52bd 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -5,14 +5,9 @@ import { getPerfectElementSize } from "./sizeHelpers"; 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 { - isArrowElement, - isBoundToContainer, - isFrameLikeElement, -} from "./typeChecks"; +import { isArrowElement, isFrameLikeElement } from "./typeChecks"; export const dragSelectedElements = ( pointerDownState: PointerDownState, @@ -37,13 +32,11 @@ export const dragSelectedElements = ( .map((f) => f.id); if (frames.length > 0) { - const elementsInFrames = scene - .getNonDeletedElements() - .filter((e) => !isBoundToContainer(e)) - .filter((e) => e.frameId !== null) - .filter((e) => frames.includes(e.frameId!)); - - elementsInFrames.forEach((element) => elementsToUpdate.add(element)); + for (const element of scene.getNonDeletedElements()) { + if (element.frameId !== null && frames.includes(element.frameId)) { + elementsToUpdate.add(element); + } + } } const commonBounds = getCommonBounds( @@ -60,23 +53,19 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords(pointerDownState, element, adjustedOffset); - // update coords of bound text only if we're dragging the container directly - // (we don't drag the group that it's part of) if ( - // Don't update coords of arrow label since we calculate its position during render - !isArrowElement(element) && - // container isn't part of any group - // (perf optim so we don't check `isSelectedViaGroup()` in every case) - (!element.groupIds.length || - // container is part of a group, but we're dragging the container directly - (appState.editingGroupId && !isSelectedViaGroup(appState, element))) + // skip arrow labels since we calculate its position during render + !isArrowElement(element) ) { - const textElement = getBoundTextElement(element); + const textElement = getBoundTextElement( + element, + scene.getNonDeletedElementsMap(), + ); if (textElement) { updateElementCoords(pointerDownState, textElement, adjustedOffset); } } - updateBoundElements(element, { + updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { simultaneouslyUpdated: Array.from(elementsToUpdate), }); }); diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index c129d3927..d82c75e90 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -1,43 +1,42 @@ import { register } from "../actions/register"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; -import { t } from "../i18n"; import { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { newTextElement } from "./newElement"; -import { getContainerElement, wrapText } from "./textElement"; -import { - isFrameLikeElement, - isIframeElement, - isIframeLikeElement, -} from "./typeChecks"; +import { wrapText } from "./textElement"; +import { isIframeElement } from "./typeChecks"; import { ExcalidrawElement, ExcalidrawIframeLikeElement, IframeData, - NonDeletedExcalidrawElement, } from "./types"; +import { sanitizeHTMLAttribute } from "../data/url"; +import { MarkRequired } from "../utility-types"; -const embeddedLinkCache = new Map(); +type IframeDataWithSandbox = MarkRequired; + +const embeddedLinkCache = new Map(); const RE_YOUTUBE = /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; const RE_VIMEO = - /^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; + /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/; -const RE_GH_GIST = /^https:\/\/gist\.github\.com/; +const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/; const RE_GH_GIST_EMBED = - /^ twitter embeds -const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/; +const RE_TWITTER = + /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; const RE_TWITTER_EMBED = - /^$/i; @@ -54,11 +53,23 @@ const ALLOWED_DOMAINS = new Set([ "link.excalidraw.com", "gist.github.com", "twitter.com", + "x.com", "*.simplepdf.eu", "stackblitz.com", "val.town", "giphy.com", - "dddice.com", +]); + +const ALLOW_SAME_ORIGIN = new Set([ + "youtube.com", + "youtu.be", + "vimeo.com", + "player.vimeo.com", + "figma.com", + "twitter.com", + "x.com", + "*.simplepdf.eu", + "stackblitz.com", ]); export const createSrcDoc = (body: string) => { @@ -67,7 +78,7 @@ export const createSrcDoc = (body: string) => { export const getEmbedLink = ( link: string | null | undefined, -): IframeData | null => { +): IframeDataWithSandbox | null => { if (!link) { return null; } @@ -78,6 +89,10 @@ export const getEmbedLink = ( const originalLink = link; + const allowSameOrigin = ALLOW_SAME_ORIGIN.has( + matchHostname(link, ALLOW_SAME_ORIGIN) || "", + ); + let type: "video" | "generic" = "generic"; let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); @@ -104,15 +119,21 @@ export const getEmbedLink = ( link, intrinsicSize: aspectRatio, type, + sandbox: { allowSameOrigin }, }); - return { link, intrinsicSize: aspectRatio, type }; + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; } const vimeoLink = link.match(RE_VIMEO); if (vimeoLink?.[1]) { const target = vimeoLink?.[1]; - const warning = !/^\d+$/.test(target) - ? t("toast.unrecognizedLinkFormat") + const error = !/^\d+$/.test(target) + ? new URIError("Invalid embed link format") : undefined; type = "video"; link = `https://player.vimeo.com/video/${target}?api=1`; @@ -123,8 +144,15 @@ export const getEmbedLink = ( link, intrinsicSize: aspectRatio, type, + sandbox: { allowSameOrigin }, }); - return { link, intrinsicSize: aspectRatio, type, warning }; + return { + link, + intrinsicSize: aspectRatio, + type, + error, + sandbox: { allowSameOrigin }, + }; } const figmaLink = link.match(RE_FIGMA); @@ -138,8 +166,14 @@ export const getEmbedLink = ( link, intrinsicSize: aspectRatio, type, + sandbox: { allowSameOrigin }, }); - return { link, intrinsicSize: aspectRatio, type }; + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; } const valLink = link.match(RE_VALTOWN); @@ -150,82 +184,74 @@ export const getEmbedLink = ( link, intrinsicSize: aspectRatio, type, + sandbox: { allowSameOrigin }, }); - return { link, intrinsicSize: aspectRatio, type }; + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; } if (RE_TWITTER.test(link)) { - let ret: IframeData; - // assume embed code - if (/
srcDoc, - intrinsicSize: { w: 480, h: 480 }, - }; - // assume regular tweet url - } else { - ret = { - type: "document", - srcdoc: (theme: string) => - createSrcDoc( - ` `, - ), - intrinsicSize: { w: 480, h: 480 }, - }; - } + const postId = link.match(RE_TWITTER)![1]; + // the embed srcdoc still supports twitter.com domain only. + // Note that we don't attempt to parse the username as it can consist of + // non-latin1 characters, and the username in the url can be set to anything + // without affecting the embed. + const safeURL = sanitizeHTMLAttribute( + `https://twitter.com/x/status/${postId}`, + ); + + const ret: IframeDataWithSandbox = { + type: "document", + srcdoc: (theme: string) => + createSrcDoc( + ` `, + ), + intrinsicSize: { w: 480, h: 480 }, + sandbox: { allowSameOrigin }, + }; embeddedLinkCache.set(originalLink, ret); return ret; } if (RE_GH_GIST.test(link)) { - let ret: IframeData; - // assume embed code - if (/ + const [, user, gistId] = link.match(RE_GH_GIST)!; + const safeURL = sanitizeHTMLAttribute( + `https://gist.github.com/${user}/${gistId}`, + ); + const ret: IframeDataWithSandbox = { + type: "document", + srcdoc: () => + createSrcDoc(` + `), - intrinsicSize: { w: 550, h: 720 }, - }; - } + intrinsicSize: { w: 550, h: 720 }, + sandbox: { allowSameOrigin }, + }; embeddedLinkCache.set(link, ret); return ret; } - embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type }); - return { link, intrinsicSize: aspectRatio, type }; -}; - -export const isIframeLikeOrItsLabel = ( - element: NonDeletedExcalidrawElement, -): Boolean => { - if (isIframeLikeElement(element)) { - return true; - } - if (element.type === "text") { - const container = getContainerElement(element); - if (container && isFrameLikeElement(container)) { - return true; - } - } - return false; + embeddedLinkCache.set(link, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; }; export const createPlaceholderEmbeddableLabel = ( @@ -268,6 +294,8 @@ export const createPlaceholderEmbeddableLabel = ( export const actionSetEmbeddableAsActiveTool = register({ name: "setEmbeddableAsActiveTool", trackEvent: { category: "toolbar" }, + target: "Tool", + label: "toolBar.embeddable", perform: (elements, appState, _, app) => { const nextActiveTool = updateActiveTool(appState, { type: "embeddable", @@ -291,56 +319,62 @@ export const actionSetEmbeddableAsActiveTool = register({ }, }); -const validateHostname = ( +const matchHostname = ( url: string, /** using a Set assumes it already contains normalized bare domains */ allowedHostnames: Set | string, -): boolean => { +): string | null => { try { const { hostname } = new URL(url); const bareDomain = hostname.replace(/^www\./, ""); - const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( - /^([^.]+)/, - "*", - ); if (allowedHostnames instanceof Set) { - return ( - ALLOWED_DOMAINS.has(bareDomain) || - ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded) + if (ALLOWED_DOMAINS.has(bareDomain)) { + return bareDomain; + } + + const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( + /^([^.]+)/, + "*", ); + if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) { + return bareDomainWithFirstSubdomainWildcarded; + } + return null; } - if (bareDomain === allowedHostnames.replace(/^www\./, "")) { - return true; + const bareAllowedHostname = allowedHostnames.replace(/^www\./, ""); + if (bareDomain === bareAllowedHostname) { + return bareAllowedHostname; } } catch (error) { // ignore } - return false; + return null; }; -export const extractSrc = (htmlString: string): string => { - const twitterMatch = htmlString.match(RE_TWITTER_EMBED); +export const maybeParseEmbedSrc = (str: string): string => { + const twitterMatch = str.match(RE_TWITTER_EMBED); if (twitterMatch && twitterMatch.length === 2) { return twitterMatch[1]; } - const gistMatch = htmlString.match(RE_GH_GIST_EMBED); + const gistMatch = str.match(RE_GH_GIST_EMBED); if (gistMatch && gistMatch.length === 2) { return gistMatch[1]; } - if (RE_GIPHY.test(htmlString)) { - return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`; + if (RE_GIPHY.test(str)) { + return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`; } - const match = htmlString.match(RE_GENERIC_EMBED); + const match = str.match(RE_GENERIC_EMBED); if (match && match.length === 2) { return match[1]; } - return htmlString; + + return str; }; export const embeddableURLValidator = ( @@ -367,7 +401,7 @@ export const embeddableURLValidator = ( if (url.match(domain)) { return true; } - } else if (validateHostname(url, domain)) { + } else if (matchHostname(url, domain)) { return true; } } @@ -375,5 +409,5 @@ export const embeddableURLValidator = ( } } - return validateHostname(url, ALLOWED_DOMAINS); + return !!matchHostname(url, ALLOWED_DOMAINS); }; diff --git a/packages/excalidraw/element/image.ts b/packages/excalidraw/element/image.ts index bd9bcd627..ad94c51e0 100644 --- a/packages/excalidraw/element/image.ts +++ b/packages/excalidraw/element/image.ts @@ -3,7 +3,6 @@ // ----------------------------------------------------------------------------- import { MIME_TYPES, SVG_NS } from "../constants"; -import { t } from "../i18n"; import { AppClassProperties, DataURL, BinaryFiles } from "../types"; import { isInitializedImageElement } from "./typeChecks"; import { @@ -100,7 +99,7 @@ export const normalizeSVG = async (SVGString: string) => { const svg = doc.querySelector("svg"); const errorNode = doc.querySelector("parsererror"); if (errorNode || !isHTMLSVGElement(svg)) { - throw new Error(t("errors.invalidSVGString")); + throw new Error("Invalid SVG"); } else { if (!svg.hasAttribute("xmlns")) { svg.setAttribute("xmlns", SVG_NS); diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 37d6a077b..e7d699dae 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -29,10 +29,6 @@ export { getTransformHandlesFromCoords, getTransformHandles, } from "./transformHandles"; -export { - hitTest, - isHittingElementBoundingBoxWithoutHittingElement, -} from "./collision"; export { resizeTest, getCursorForResizingElement, @@ -50,7 +46,6 @@ export { dragNewElement, } from "./dragElements"; export { isTextElement, isExcalidrawElement } from "./typeChecks"; -export { textWysiwyg } from "./textWysiwyg"; export { redrawTextBoundingBox } from "./textElement"; export { getPerfectElementSize, @@ -61,9 +56,36 @@ export { } from "./sizeHelpers"; export { showSelectedShapeActions } from "./showSelectedShapeActions"; +/** + * @deprecated unsafe, use hashElementsVersion instead + */ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) => elements.reduce((acc, el) => acc + el.version, 0); +/** + * Hashes elements' versionNonce (using djb2 algo). Order of elements matters. + */ +export const hashElementsVersion = ( + elements: readonly ExcalidrawElement[], +): number => { + let hash = 5381; + for (let i = 0; i < elements.length; i++) { + hash = (hash << 5) + hash + elements[i].versionNonce; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + +// string hash function (using djb2). Not cryptographically secure, use only +// for versioning and such. +export const hashString = (s: string): number => { + let hash: number = 5381; + for (let i = 0; i < s.length; i++) { + const char: number = s.charCodeAt(i); + hash = (hash << 5) + hash + char; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + export const getVisibleElements = (elements: readonly ExcalidrawElement[]) => elements.filter( (el) => !el.isDeleted && !isInvisiblySmallElement(el), diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index bf64ee732..29fa65c35 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -5,6 +5,8 @@ import { PointBinding, ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, + ElementsMap, + NonDeletedSceneElementsMap, } from "./types"; import { distance2d, @@ -31,11 +33,11 @@ import { AppState, PointerCoords, InteractiveCanvasAppState, + AppClassProperties, } from "../types"; import { mutateElement } from "./mutateElement"; import History from "../history"; -import Scene from "../scene/Scene"; import { bindOrUnbindLinearElement, getHoveredElementForBinding, @@ -85,11 +87,10 @@ export class LinearElementEditor { public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: Point | null; - constructor(element: NonDeleted, scene: Scene) { + constructor(element: NonDeleted) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; }; - Scene.mapElementToScene(this.elementId, scene); LinearElementEditor.normalizePoints(element); this.selectedPointsIndices = null; @@ -122,8 +123,11 @@ export class LinearElementEditor { * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) */ - static getElement(id: InstanceType["elementId"]) { - const element = Scene.getScene(id)?.getNonDeletedElement(id); + static getElement( + id: InstanceType["elementId"], + elementsMap: ElementsMap, + ) { + const element = elementsMap.get(id); if (element) { return element as NonDeleted; } @@ -134,6 +138,7 @@ export class LinearElementEditor { event: PointerEvent, appState: AppState, setState: React.Component["setState"], + elementsMap: NonDeletedSceneElementsMap, ) { if ( !appState.editingLinearElement || @@ -144,16 +149,18 @@ export class LinearElementEditor { const { editingLinearElement } = appState; const { selectedPointsIndices, elementId } = editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(appState.draggingElement); + getElementAbsoluteCoords(appState.draggingElement, elementsMap); - const pointsSceneCoords = - LinearElementEditor.getPointsGlobalCoordinates(element); + const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); const nextSelectedPoints = pointsSceneCoords.reduce( (acc: number[], point, index) => { @@ -193,12 +200,13 @@ export class LinearElementEditor { pointSceneCoords: { x: number; y: number }[], ) => void, linearElementEditor: LinearElementEditor, + elementsMap: NonDeletedSceneElementsMap, ): boolean { if (!linearElementEditor) { return false; } const { selectedPointsIndices, elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -220,6 +228,7 @@ export class LinearElementEditor { const [width, height] = LinearElementEditor._getShiftLockedDelta( element, + elementsMap, referencePoint, [scenePointerX, scenePointerY], event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -237,6 +246,7 @@ export class LinearElementEditor { } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -253,6 +263,7 @@ export class LinearElementEditor { linearElementEditor.pointerDownState.lastClickedPoint ? LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -272,9 +283,9 @@ export class LinearElementEditor { ); } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { - handleBindTextResize(element, false); + handleBindTextResize(element, elementsMap, false); } // suggest bindings for first and last point if selected @@ -288,6 +299,7 @@ export class LinearElementEditor { LinearElementEditor.getPointGlobalCoordinates( element, element.points[0], + elementsMap, ), ), ); @@ -301,6 +313,7 @@ export class LinearElementEditor { LinearElementEditor.getPointGlobalCoordinates( element, element.points[lastSelectedIndex], + elementsMap, ), ), ); @@ -321,10 +334,13 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, + app: AppClassProperties, ): LinearElementEditor { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return editingLinearElement; } @@ -362,9 +378,10 @@ export class LinearElementEditor { LinearElementEditor.getPointAtIndexGlobalCoordinates( element, selectedPoint!, + elementsMap, ), ), - Scene.getScene(element)!, + app, ) : null; @@ -404,9 +421,10 @@ export class LinearElementEditor { static getEditorMidPoints = ( element: NonDeleted, + elementsMap: ElementsMap, appState: InteractiveCanvasAppState, ): typeof editorMidPointsCache["points"] => { - const boundText = getBoundTextElement(element); + const boundText = getBoundTextElement(element, elementsMap); // Since its not needed outside editor unless 2 pointer lines or bound text if ( @@ -422,15 +440,23 @@ export class LinearElementEditor { ) { return editorMidPointsCache.points; } - LinearElementEditor.updateEditorMidPointsCache(element, appState); + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); return editorMidPointsCache.points!; }; static updateEditorMidPointsCache = ( element: NonDeleted, + elementsMap: ElementsMap, appState: InteractiveCanvasAppState, ) => { - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); let index = 0; const midpoints: (Point | null)[] = []; @@ -452,6 +478,7 @@ export class LinearElementEditor { points[index], points[index + 1], index + 1, + elementsMap, ); midpoints.push(segmentMidPoint); index++; @@ -465,14 +492,16 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, scenePointer: { x: number; y: number }, appState: AppState, + elementsMap: ElementsMap, ) => { const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return null; } const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, appState.zoom, scenePointer.x, scenePointer.y, @@ -480,7 +509,10 @@ export class LinearElementEditor { if (clickedPointIndex >= 0) { return null; } - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); if (points.length >= 3 && !appState.editingLinearElement) { return null; } @@ -503,7 +535,7 @@ export class LinearElementEditor { } let index = 0; const midPoints: typeof editorMidPointsCache["points"] = - LinearElementEditor.getEditorMidPoints(element, appState); + LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); while (index < midPoints.length) { if (midPoints[index] !== null) { const distance = distance2d( @@ -546,6 +578,7 @@ export class LinearElementEditor { startPoint: Point, endPoint: Point, endPointIndex: number, + elementsMap: ElementsMap, ) { let segmentMidPoint = centerPoint(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { @@ -570,6 +603,7 @@ export class LinearElementEditor { segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( element, [tx, ty], + elementsMap, ); } } @@ -581,14 +615,20 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, appState: AppState, midPoint: Point, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { return -1; } - const midPoints = LinearElementEditor.getEditorMidPoints(element, appState); + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ); let index = 0; while (index < midPoints.length) { if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) { @@ -605,11 +645,14 @@ export class LinearElementEditor { history: History, scenePointer: { x: number; y: number }, linearElementEditor: LinearElementEditor, + app: AppClassProperties, ): { didAddPoint: boolean; hitElement: NonDeleted | null; linearElementEditor: LinearElementEditor | null; } { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const ret: ReturnType = { didAddPoint: false, hitElement: null, @@ -621,7 +664,7 @@ export class LinearElementEditor { } const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return ret; @@ -630,6 +673,7 @@ export class LinearElementEditor { linearElementEditor, scenePointer, appState, + elementsMap, ); let segmentMidpointIndex = null; if (segmentMidpoint) { @@ -637,6 +681,7 @@ export class LinearElementEditor { linearElementEditor, appState, segmentMidpoint, + elementsMap, ); } if (event.altKey && appState.editingLinearElement) { @@ -646,6 +691,7 @@ export class LinearElementEditor { ...element.points, LinearElementEditor.createPointAt( element, + elementsMap, scenePointer.x, scenePointer.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -669,10 +715,7 @@ export class LinearElementEditor { }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding( - scenePointer, - Scene.getScene(element)!, - ), + endBindingElement: getHoveredElementForBinding(scenePointer, app), }; ret.didAddPoint = true; @@ -681,6 +724,7 @@ export class LinearElementEditor { const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, appState.zoom, scenePointer.x, scenePointer.y, @@ -701,11 +745,12 @@ export class LinearElementEditor { element, startBindingElement, endBindingElement, + elementsMap, ); } } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const targetPoint = @@ -767,12 +812,13 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, appState: AppState, + elementsMap: ElementsMap, ): LinearElementEditor | null { if (!appState.editingLinearElement) { return null; } const { elementId, lastUncommittedPoint } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return appState.editingLinearElement; } @@ -797,6 +843,7 @@ export class LinearElementEditor { const [width, height] = LinearElementEditor._getShiftLockedDelta( element, + elementsMap, lastCommittedPoint, [scenePointerX, scenePointerY], event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -809,6 +856,7 @@ export class LinearElementEditor { } else { newPoint = LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerY - appState.editingLinearElement.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -835,8 +883,9 @@ export class LinearElementEditor { static getPointGlobalCoordinates( element: NonDeleted, point: Point, + elementsMap: ElementsMap, ) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -848,8 +897,9 @@ export class LinearElementEditor { /** scene coords */ static getPointsGlobalCoordinates( element: NonDeleted, + elementsMap: ElementsMap, ): Point[] { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; return element.points.map((point) => { @@ -861,13 +911,15 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, + indexMaybeFromEnd: number, // -1 for last element + elementsMap: ElementsMap, ): Point { const index = indexMaybeFromEnd < 0 ? element.points.length + indexMaybeFromEnd : indexMaybeFromEnd; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -881,8 +933,9 @@ export class LinearElementEditor { static pointFromAbsoluteCoords( element: NonDeleted, absoluteCoords: Point, + elementsMap: ElementsMap, ): Point { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const [x, y] = rotate( @@ -897,12 +950,15 @@ export class LinearElementEditor { static getPointIndexUnderCursor( element: NonDeleted, + elementsMap: ElementsMap, zoom: AppState["zoom"], x: number, y: number, ) { - const pointHandles = - LinearElementEditor.getPointsGlobalCoordinates(element); + const pointHandles = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); let idx = pointHandles.length; // loop from right to left because points on the right are rendered over // points on the left, thus should take precedence when clicking, if they @@ -922,12 +978,13 @@ export class LinearElementEditor { static createPointAt( element: NonDeleted, + elementsMap: ElementsMap, scenePointerX: number, scenePointerY: number, gridSize: number | null, ): Point { const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const [rotatedX, rotatedY] = rotate( @@ -968,14 +1025,14 @@ export class LinearElementEditor { mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); } - static duplicateSelectedPoints(appState: AppState) { + static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) { if (!appState.editingLinearElement) { return false; } const { selectedPointsIndices, elementId } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element || selectedPointsIndices === null) { return false; @@ -1137,9 +1194,11 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, pointerCoords: PointerCoords, appState: AppState, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { @@ -1178,9 +1237,11 @@ export class LinearElementEditor { pointerCoords: PointerCoords, appState: AppState, snapToGrid: boolean, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { return; @@ -1196,6 +1257,7 @@ export class LinearElementEditor { const midpoint = LinearElementEditor.createPointAt( element, + elementsMap, pointerCoords.x, pointerCoords.y, snapToGrid ? appState.gridSize : null, @@ -1248,6 +1310,7 @@ export class LinearElementEditor { private static _getShiftLockedDelta( element: NonDeleted, + elementsMap: ElementsMap, referencePoint: Point, scenePointer: Point, gridSize: number | null, @@ -1255,6 +1318,7 @@ export class LinearElementEditor { const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( element, referencePoint, + elementsMap, ); const [gridX, gridY] = getGridPoint( @@ -1276,8 +1340,12 @@ export class LinearElementEditor { static getBoundTextElementPosition = ( element: ExcalidrawLinearElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ): { x: number; y: number } => { - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); if (points.length < 2) { mutateElement(boundTextElement, { isDeleted: true }); } @@ -1288,6 +1356,7 @@ export class LinearElementEditor { const midPoint = LinearElementEditor.getPointGlobalCoordinates( element, element.points[index], + elementsMap, ); x = midPoint[0] - boundTextElement.width / 2; y = midPoint[1] - boundTextElement.height / 2; @@ -1307,6 +1376,7 @@ export class LinearElementEditor { points[index], points[index + 1], index + 1, + elementsMap, ); } x = midSegmentMidpoint[0] - boundTextElement.width / 2; @@ -1317,6 +1387,7 @@ export class LinearElementEditor { static getMinMaxXYWithBoundText = ( element: ExcalidrawLinearElement, + elementsMap: ElementsMap, elementBounds: Bounds, boundTextElement: ExcalidrawTextElementWithContainer, ): [number, number, number, number, number, number] => { @@ -1327,6 +1398,7 @@ export class LinearElementEditor { LinearElementEditor.getBoundTextElementPosition( element, boundTextElement, + elementsMap, ); const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextY2 = boundTextY1 + boundTextElement.height; @@ -1418,6 +1490,7 @@ export class LinearElementEditor { static getElementAbsoluteCoords = ( element: ExcalidrawLinearElement, + elementsMap: ElementsMap, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { let coords: [number, number, number, number, number, number]; @@ -1462,10 +1535,11 @@ export class LinearElementEditor { if (!includeBoundText) { return coords; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { coords = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, [x1, y1, x2, y2], boundTextElement, ); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 91b30beb7..34dec0adb 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -16,6 +16,7 @@ import { ExcalidrawEmbeddableElement, ExcalidrawMagicFrameElement, ExcalidrawIframeElement, + ElementsMap, } from "./types"; import { arrayToMap, @@ -31,7 +32,6 @@ import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { - getContainerElement, measureText, normalizeText, wrapText, @@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional< | "angle" | "groupIds" | "frameId" + | "index" | "boundElements" | "seed" | "version" @@ -69,6 +70,7 @@ export type ElementConstructorOpts = MarkOptional< | "roundness" | "locked" | "opacity" + | "customData" >; const _newElementBase = ( @@ -88,6 +90,7 @@ const _newElementBase = ( angle = 0, groupIds = [], frameId = null, + index = null, roundness = null, boundElements = null, link = null, @@ -113,6 +116,7 @@ const _newElementBase = ( opacity, groupIds, frameId, + index, roundness, seed: rest.seed ?? randomInteger(), version: rest.version || 1, @@ -122,6 +126,7 @@ const _newElementBase = ( updated: getUpdatedTimestamp(), link, locked, + customData: rest.customData, }; return element; }; @@ -136,13 +141,9 @@ export const newElement = ( export const newEmbeddableElement = ( opts: { type: "embeddable"; - validated: ExcalidrawEmbeddableElement["validated"]; } & ElementConstructorOpts, ): NonDeleted => { - return { - ..._newElementBase("embeddable", opts), - validated: opts.validated, - }; + return _newElementBase("embeddable", opts); }; export const newIframeElement = ( @@ -251,7 +252,6 @@ export const newTextElement = ( y: opts.y - offsets.y, width: metrics.width, height: metrics.height, - baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, lineHeight, @@ -263,19 +263,19 @@ export const newTextElement = ( const getAdjustedDimensions = ( element: ExcalidrawTextElement, + elementsMap: ElementsMap, nextText: string, ): { x: number; y: number; width: number; height: number; - baseline: number; } => { - const { - width: nextWidth, - height: nextHeight, - baseline: nextBaseline, - } = measureText(nextText, getFontString(element), element.lineHeight); + const { width: nextWidth, height: nextHeight } = measureText( + nextText, + getFontString(element), + element.lineHeight, + ); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -297,7 +297,7 @@ const getAdjustedDimensions = ( x = element.x - offsets.x; y = element.y - offsets.y; } else { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( element, @@ -329,7 +329,6 @@ const getAdjustedDimensions = ( return { width: nextWidth, height: nextHeight, - baseline: nextBaseline, x: Number.isFinite(x) ? x : element.x, y: Number.isFinite(y) ? y : element.y, }; @@ -337,25 +336,28 @@ const getAdjustedDimensions = ( export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, text = textElement.text, ) => { if (textElement.isDeleted) { return; } - const container = getContainerElement(textElement); if (container) { text = wrapText( text, getFontString(textElement), - getBoundTextMaxWidth(container), + getBoundTextMaxWidth(container, textElement), ); } - const dimensions = getAdjustedDimensions(textElement, text); + const dimensions = getAdjustedDimensions(textElement, elementsMap, text); return { text, ...dimensions }; }; export const updateTextElement = ( textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, { text, isDeleted, @@ -369,7 +371,7 @@ export const updateTextElement = ( return newElementWith(textElement, { originalText, isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, originalText), + ...refreshTextDimensions(textElement, container, elementsMap, originalText), }); }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 883382933..e18a4ed25 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -15,6 +15,7 @@ import { ExcalidrawElement, ExcalidrawTextElementWithContainer, ExcalidrawImageElement, + ElementsMap, } from "./types"; import type { Mutable } from "../utility-types"; import { @@ -41,7 +42,7 @@ import { MaybeTransformHandleType, TransformHandleDirection, } from "./transformHandles"; -import { AppState, Point, PointerDownState } from "../types"; +import { Point, PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { getApproxMinLineWidth, @@ -51,8 +52,6 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, - measureText, - getBoundTextMaxHeight, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; @@ -68,10 +67,10 @@ export const normalizeAngle = (angle: number): number => { // Returns true when transform (resizing/rotation) happened export const transformElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], transformHandleType: MaybeTransformHandleType, selectedElements: readonly NonDeletedExcalidrawElement[], - resizeArrowDirection: "origin" | "end", + elementsMap: ElementsMap, shouldRotateWithDiscreteAngle: boolean, shouldResizeFromCenter: boolean, shouldMaintainAspectRatio: boolean, @@ -79,19 +78,18 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, - appState: AppState, ) => { if (selectedElements.length === 1) { const [element] = selectedElements; if (transformHandleType === "rotation") { rotateSingleElement( element, + elementsMap, pointerX, pointerY, shouldRotateWithDiscreteAngle, - pointerDownState.originalElements, ); - updateBoundElements(element); + updateBoundElements(element, elementsMap); } else if ( isTextElement(element) && (transformHandleType === "nw" || @@ -101,17 +99,19 @@ export const transformElements = ( ) { resizeSingleTextElement( element, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, pointerY, ); - updateBoundElements(element); + updateBoundElements(element, elementsMap); } else if (transformHandleType) { resizeSingleElement( - pointerDownState.originalElements, + originalElements, shouldMaintainAspectRatio, element, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -123,8 +123,9 @@ export const transformElements = ( } else if (selectedElements.length > 1) { if (transformHandleType === "rotation") { rotateMultipleElements( - pointerDownState, + originalElements, selectedElements, + elementsMap, pointerX, pointerY, shouldRotateWithDiscreteAngle, @@ -139,8 +140,9 @@ export const transformElements = ( transformHandleType === "se" ) { resizeMultipleElements( - pointerDownState, + originalElements, selectedElements, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -154,12 +156,12 @@ export const transformElements = ( const rotateSingleElement = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, - originalElements: Map>, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; let angle: number; @@ -207,31 +209,26 @@ const rescalePointsInElement = ( const measureFontSizeFromWidth = ( element: NonDeleted, + elementsMap: ElementsMap, nextWidth: number, - nextHeight: number, -): { size: number; baseline: number } | null => { +): { size: number } | null => { // We only use width to scale font on resize let width = element.width; const hasContainer = isBoundToContainer(element); if (hasContainer) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (container) { - width = getBoundTextMaxWidth(container); + width = getBoundTextMaxWidth(container, element); } } const nextFontSize = element.fontSize * (nextWidth / width); if (nextFontSize < MIN_FONT_SIZE) { return null; } - const metrics = measureText( - element.text, - getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - element.lineHeight, - ); + return { size: nextFontSize, - baseline: metrics.baseline + (nextHeight - metrics.height), }; }; @@ -257,12 +254,13 @@ const getSidesForTransformHandle = ( const resizeSingleTextElement = ( element: NonDeleted, + elementsMap: ElementsMap, transformHandleType: "nw" | "ne" | "sw" | "se", shouldResizeFromCenter: boolean, pointerX: number, pointerY: number, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; // rotation pointer with reverse angle @@ -303,7 +301,7 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight); + const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth); if (metrics === null) { return; } @@ -331,7 +329,6 @@ const resizeSingleTextElement = ( fontSize: metrics.size, width: nextWidth, height: nextHeight, - baseline: metrics.baseline, x: nextElementX, y: nextElementY, }); @@ -342,6 +339,7 @@ export const resizeSingleElement = ( originalElements: PointerDownState["originalElements"], shouldMaintainAspectRatio: boolean, element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, transformHandleDirection: TransformHandleDirection, shouldResizeFromCenter: boolean, pointerX: number, @@ -384,8 +382,8 @@ export const resizeSingleElement = ( let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleY = atStartBoundsHeight / boundsCurrentHeight; - let boundTextFont: { fontSize?: number; baseline?: number } = {}; - const boundTextElement = getBoundTextElement(element); + let boundTextFont: { fontSize?: number } = {}; + const boundTextElement = getBoundTextElement(element, elementsMap); if (transformHandleDirection.includes("e")) { scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; @@ -436,7 +434,6 @@ export const resizeSingleElement = ( if (stateOfBoundTextElementAtResize) { boundTextFont = { fontSize: stateOfBoundTextElementAtResize.fontSize, - baseline: stateOfBoundTextElementAtResize.baseline, }; } if (shouldMaintainAspectRatio) { @@ -448,15 +445,14 @@ export const resizeSingleElement = ( const nextFont = measureFontSizeFromWidth( boundTextElement, - getBoundTextMaxWidth(updatedElement), - getBoundTextMaxHeight(updatedElement, boundTextElement), + elementsMap, + getBoundTextMaxWidth(updatedElement, boundTextElement), ); if (nextFont === null) { return; } boundTextFont = { fontSize: nextFont.size, - baseline: nextFont.baseline, }; } else { const minWidth = getApproxMinLineWidth( @@ -618,18 +614,18 @@ export const resizeSingleElement = ( ) { mutateElement(element, resizedElement); - updateBoundElements(element, { + updateBoundElements(element, elementsMap, { newSize: { width: resizedElement.width, height: resizedElement.height }, }); if (boundTextElement && boundTextFont != null) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, - baseline: boundTextFont.baseline, }); } handleBindTextResize( element, + elementsMap, transformHandleDirection, shouldMaintainAspectRatio, ); @@ -637,8 +633,9 @@ export const resizeSingleElement = ( }; export const resizeMultipleElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], selectedElements: readonly NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, transformHandleType: "nw" | "ne" | "sw" | "se", shouldResizeFromCenter: boolean, pointerX: number, @@ -658,7 +655,7 @@ export const resizeMultipleElements = ( }[], element, ) => { - const origElement = pointerDownState.originalElements.get(element.id); + const origElement = originalElements.get(element.id); if (origElement) { acc.push({ orig: origElement, latest: element }); } @@ -679,11 +676,15 @@ export const resizeMultipleElements = ( if (!textId) { return acc; } - const text = pointerDownState.originalElements.get(textId) ?? null; + const text = originalElements.get(textId) ?? null; if (!isBoundToContainer(text)) { return acc; } - const xy = LinearElementEditor.getBoundTextElementPosition(orig, text); + const xy = LinearElementEditor.getBoundTextElementPosition( + orig, + text, + elementsMap, + ); return [...acc, { ...text, ...xy }]; }, [] as ExcalidrawTextElementWithContainer[]); @@ -750,7 +751,6 @@ export const resizeMultipleElements = ( > & { points?: ExcalidrawLinearElement["points"]; fontSize?: ExcalidrawTextElement["fontSize"]; - baseline?: ExcalidrawTextElement["baseline"]; scale?: ExcalidrawImageElement["scale"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"]; }; @@ -825,15 +825,14 @@ export const resizeMultipleElements = ( } if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth(orig, width, height); + const metrics = measureFontSizeFromWidth(orig, elementsMap, width); if (!metrics) { return; } update.fontSize = metrics.size; - update.baseline = metrics.baseline; } - const boundTextElement = pointerDownState.originalElements.get( + const boundTextElement = originalElements.get( getBoundTextElementId(orig) ?? "", ) as ExcalidrawTextElementWithContainer | undefined; @@ -861,12 +860,12 @@ export const resizeMultipleElements = ( mutateElement(element, update, false); - updateBoundElements(element, { + updateBoundElements(element, elementsMap, { simultaneouslyUpdated: elementsToUpdate, newSize: { width, height }, }); - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement && boundTextFontSize) { mutateElement( boundTextElement, @@ -876,7 +875,7 @@ export const resizeMultipleElements = ( }, false, ); - handleBindTextResize(element, transformHandleType, true); + handleBindTextResize(element, elementsMap, transformHandleType, true); } } @@ -884,8 +883,9 @@ export const resizeMultipleElements = ( }; const rotateMultipleElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], elements: readonly NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, @@ -902,12 +902,11 @@ const rotateMultipleElements = ( elements .filter((element) => !isFrameLikeElement(element)) .forEach((element) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const origAngle = - pointerDownState.originalElements.get(element.id)?.angle ?? - element.angle; + originalElements.get(element.id)?.angle ?? element.angle; const [rotatedCX, rotatedCY] = rotate( cx, cy, @@ -924,9 +923,11 @@ const rotateMultipleElements = ( }, false, ); - updateBoundElements(element, { simultaneouslyUpdated: elements }); + updateBoundElements(element, elementsMap, { + simultaneouslyUpdated: elements, + }); - const boundText = getBoundTextElement(element); + const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { mutateElement( boundText, @@ -946,12 +947,13 @@ const rotateMultipleElements = ( export const getResizeOffsetXY = ( transformHandleType: MaybeTransformHandleType, selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, x: number, y: number, ): [number, number] => { const [x1, y1, x2, y2] = selectedElements.length === 1 - ? getElementAbsoluteCoords(selectedElements[0]) + ? getElementAbsoluteCoords(selectedElements[0], elementsMap) : getCommonBounds(selectedElements); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 9947a6082..2e01f94d9 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -2,6 +2,7 @@ import { ExcalidrawElement, PointerType, NonDeletedExcalidrawElement, + ElementsMap, } from "./types"; import { @@ -27,6 +28,7 @@ const isInsideTransformHandle = ( export const resizeTest = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, x: number, y: number, @@ -38,7 +40,7 @@ export const resizeTest = ( } const { rotation: rotationTransformHandle, ...transformHandles } = - getTransformHandles(element, zoom, pointerType); + getTransformHandles(element, zoom, elementsMap, pointerType); if ( rotationTransformHandle && @@ -70,6 +72,7 @@ export const getElementWithTransformHandleType = ( scenePointerY: number, zoom: Zoom, pointerType: PointerType, + elementsMap: ElementsMap, ) => { return elements.reduce((result, element) => { if (result) { @@ -77,6 +80,7 @@ export const getElementWithTransformHandleType = ( } const transformHandleType = resizeTest( element, + elementsMap, appState, scenePointerX, scenePointerY, diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts index 1b69ca0bc..e30ea9877 100644 --- a/packages/excalidraw/element/sizeHelpers.ts +++ b/packages/excalidraw/element/sizeHelpers.ts @@ -1,4 +1,4 @@ -import { ExcalidrawElement } from "./types"; +import { ElementsMap, ExcalidrawElement } from "./types"; import { mutateElement } from "./mutateElement"; import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { SHIFT_LOCKING_ANGLE } from "../constants"; @@ -26,8 +26,9 @@ export const isElementInViewport = ( scrollX: number; scrollY: number; }, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates const topLeftSceneCoords = viewportCoordsToSceneCoords( { clientX: viewTransformations.offsetLeft, diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index b6221336d..2f3a2dcc7 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -319,17 +319,17 @@ describe("Test measureText", () => { it("should return max width when container is rectangle", () => { const container = API.createElement({ type: "rectangle", ...params }); - expect(getBoundTextMaxWidth(container)).toBe(168); + expect(getBoundTextMaxWidth(container, null)).toBe(168); }); it("should return max width when container is ellipse", () => { const container = API.createElement({ type: "ellipse", ...params }); - expect(getBoundTextMaxWidth(container)).toBe(116); + expect(getBoundTextMaxWidth(container, null)).toBe(116); }); it("should return max width when container is diamond", () => { const container = API.createElement({ type: "diamond", ...params }); - expect(getBoundTextMaxWidth(container)).toBe(79); + expect(getBoundTextMaxWidth(container, null)).toBe(79); }); }); diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index f812b8577..6f45561f8 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -1,5 +1,6 @@ -import { getFontString, arrayToMap, isTestEnv } from "../utils"; +import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { + ElementsMap, ExcalidrawElement, ExcalidrawElementType, ExcalidrawTextContainer, @@ -17,43 +18,36 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, - isSafari, TEXT_ALIGN, VERTICAL_ALIGN, } from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; -import Scene from "../scene/Scene"; import { isTextElement } from "."; import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; import { AppState } from "../types"; -import { isTextBindableContainer } from "./typeChecks"; -import { getElementAbsoluteCoords } from "."; -import { getSelectedElements } from "../scene"; -import { isHittingElementNotConsideringBoundingBox } from "./collision"; import { resetOriginalContainerCache, updateOriginalContainerCache, -} from "./textWysiwyg"; -import { ExtractSetType } from "../utility-types"; +} from "./containerCache"; +import { ExtractSetType, MakeBrand } from "../utility-types"; export const normalizeText = (text: string) => { return ( - text + normalizeEOL(text) // replace tabs with spaces so they render and measure correctly .replace(/\t/g, " ") - // normalize newlines - .replace(/\r?\n|\r/g, "\n") ); }; -export const splitIntoLines = (text: string) => { +const splitIntoLines = (text: string) => { return normalizeText(text).split("\n"); }; export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, + elementsMap: ElementsMap, ) => { let maxWidth = undefined; const boundTextUpdates = { @@ -62,7 +56,6 @@ export const redrawTextBoundingBox = ( text: textElement.text, width: textElement.width, height: textElement.height, - baseline: textElement.baseline, }; boundTextUpdates.text = textElement.text; @@ -83,14 +76,13 @@ export const redrawTextBoundingBox = ( boundTextUpdates.width = metrics.width; boundTextUpdates.height = metrics.height; - boundTextUpdates.baseline = metrics.baseline; if (container) { const maxContainerHeight = getBoundTextMaxHeight( container, textElement as ExcalidrawTextElementWithContainer, ); - const maxContainerWidth = getBoundTextMaxWidth(container); + const maxContainerWidth = getBoundTextMaxWidth(container, textElement); if (!isArrowElement(container) && metrics.height > maxContainerHeight) { const nextHeight = computeContainerDimensionForBoundText( @@ -111,7 +103,11 @@ export const redrawTextBoundingBox = ( ...textElement, ...boundTextUpdates, } as ExcalidrawTextElementWithContainer; - const { x, y } = computeBoundTextPosition(container, updatedTextElement); + const { x, y } = computeBoundTextPosition( + container, + updatedTextElement, + elementsMap, + ); boundTextUpdates.x = x; boundTextUpdates.y = y; } @@ -120,11 +116,11 @@ export const redrawTextBoundingBox = ( }; export const bindTextToShapeAfterDuplication = ( - sceneElements: ExcalidrawElement[], + newElements: ExcalidrawElement[], oldElements: ExcalidrawElement[], oldIdToDuplicatedId: Map, ): void => { - const sceneElementMap = arrayToMap(sceneElements) as Map< + const newElementsMap = arrayToMap(newElements) as Map< ExcalidrawElement["id"], ExcalidrawElement >; @@ -135,7 +131,7 @@ export const bindTextToShapeAfterDuplication = ( if (boundTextElementId) { const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId); if (newTextElementId) { - const newContainer = sceneElementMap.get(newElementId); + const newContainer = newElementsMap.get(newElementId); if (newContainer) { mutateElement(newContainer, { boundElements: (element.boundElements || []) @@ -150,7 +146,7 @@ export const bindTextToShapeAfterDuplication = ( }), }); } - const newTextElement = sceneElementMap.get(newTextElementId); + const newTextElement = newElementsMap.get(newTextElementId); if (newTextElement && isTextElement(newTextElement)) { mutateElement(newTextElement, { containerId: newContainer ? newElementId : null, @@ -163,6 +159,7 @@ export const bindTextToShapeAfterDuplication = ( export const handleBindTextResize = ( container: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, transformHandleType: MaybeTransformHandleType, shouldMaintainAspectRatio = false, ) => { @@ -171,27 +168,18 @@ export const handleBindTextResize = ( return; } resetOriginalContainerCache(container.id); - let textElement = Scene.getScene(container)!.getElement( - boundTextElementId, - ) as ExcalidrawTextElement; + const textElement = getBoundTextElement(container, elementsMap); if (textElement && textElement.text) { if (!container) { return; } - textElement = Scene.getScene(container)!.getElement( - boundTextElementId, - ) as ExcalidrawTextElement; let text = textElement.text; let nextHeight = textElement.height; let nextWidth = textElement.width; - const maxWidth = getBoundTextMaxWidth(container); - const maxHeight = getBoundTextMaxHeight( - container, - textElement as ExcalidrawTextElementWithContainer, - ); + const maxWidth = getBoundTextMaxWidth(container, textElement); + const maxHeight = getBoundTextMaxHeight(container, textElement); let containerHeight = container.height; - let nextBaseLine = textElement.baseline; if ( shouldMaintainAspectRatio || (transformHandleType !== "n" && transformHandleType !== "s") @@ -210,7 +198,6 @@ export const handleBindTextResize = ( ); nextHeight = metrics.height; nextWidth = metrics.width; - nextBaseLine = metrics.baseline; } // increase height in case text element height exceeds if (nextHeight > maxHeight) { @@ -238,16 +225,12 @@ export const handleBindTextResize = ( text, width: nextWidth, height: nextHeight, - baseline: nextBaseLine, }); if (!isArrowElement(container)) { mutateElement( textElement, - computeBoundTextPosition( - container, - textElement as ExcalidrawTextElementWithContainer, - ), + computeBoundTextPosition(container, textElement, elementsMap), ); } } @@ -256,16 +239,18 @@ export const handleBindTextResize = ( export const computeBoundTextPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ) => { if (isArrowElement(container)) { return LinearElementEditor.getBoundTextElementPosition( container, boundTextElement, + elementsMap, ); } const containerCoords = getContainerCoords(container); const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement); - const maxContainerWidth = getBoundTextMaxWidth(container); + const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement); let x; let y; @@ -289,8 +274,6 @@ export const computeBoundTextPosition = ( return { x, y }; }; -// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js - export const measureText = ( text: string, font: FontString, @@ -305,59 +288,7 @@ export const measureText = ( const fontSize = parseFloat(font); const height = getTextHeight(text, fontSize, lineHeight); const width = getTextWidth(text, font); - const baseline = measureBaseline(text, font, lineHeight); - return { width, height, baseline }; -}; - -export const measureBaseline = ( - text: string, - font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], - wrapInContainer?: boolean, -) => { - const container = document.createElement("div"); - container.style.position = "absolute"; - container.style.whiteSpace = "pre"; - container.style.font = font; - container.style.minHeight = "1em"; - if (wrapInContainer) { - container.style.overflow = "hidden"; - container.style.wordBreak = "break-word"; - container.style.whiteSpace = "pre-wrap"; - } - - container.style.lineHeight = String(lineHeight); - - container.innerText = text; - - // Baseline is important for positioning text on canvas - document.body.appendChild(container); - - const span = document.createElement("span"); - span.style.display = "inline-block"; - span.style.overflow = "hidden"; - span.style.width = "1px"; - span.style.height = "1px"; - container.appendChild(span); - let baseline = span.offsetTop + span.offsetHeight; - const height = container.offsetHeight; - - if (isSafari) { - const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight); - const fontSize = parseFloat(font); - // In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs - // from the actual canvas height - const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight); - if (canvasHeight > height) { - baseline += canvasHeight - domHeight; - } - - if (height > canvasHeight) { - baseline -= domHeight - canvasHeight; - } - } - document.body.removeChild(container); - return baseline; + return { width, height }; }; /** @@ -382,6 +313,24 @@ export const getLineHeightInPx = ( return fontSize * lineHeight; }; +/** + * Calculates vertical offset for a text with alphabetic baseline. + */ +export const getVerticalOffset = ( + fontFamily: ExcalidrawTextElement["fontFamily"], + fontSize: ExcalidrawTextElement["fontSize"], + lineHeightPx: number, +) => { + const { unitsPerEm, ascender, descender } = + FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica]; + + const fontSizeEm = fontSize / unitsPerEm; + const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender; + + const verticalOffset = fontSizeEm * ascender + lineGap; + return verticalOffset; +}; + // FIXME rename to getApproxMinContainerHeight export const getApproxMinLineHeight = ( fontSize: ExcalidrawTextElement["fontSize"], @@ -668,33 +617,32 @@ export const getBoundTextElementId = (container: ExcalidrawElement | null) => { : null; }; -export const getBoundTextElement = (element: ExcalidrawElement | null) => { +export const getBoundTextElement = ( + element: ExcalidrawElement | null, + elementsMap: ElementsMap, +) => { if (!element) { return null; } const boundTextElementId = getBoundTextElementId(element); + if (boundTextElementId) { - return ( - (Scene.getScene(element)?.getElement( - boundTextElementId, - ) as ExcalidrawTextElementWithContainer) || null - ); + return (elementsMap.get(boundTextElementId) || + null) as ExcalidrawTextElementWithContainer | null; } return null; }; export const getContainerElement = ( - element: - | (ExcalidrawElement & { - containerId: ExcalidrawElement["id"] | null; - }) - | null, -) => { + element: ExcalidrawTextElement | null, + elementsMap: ElementsMap, +): ExcalidrawTextContainer | null => { if (!element) { return null; } if (element.containerId) { - return Scene.getScene(element)?.getElement(element.containerId) || null; + return (elementsMap.get(element.containerId) || + null) as ExcalidrawTextContainer | null; } return null; }; @@ -702,6 +650,7 @@ export const getContainerElement = ( export const getContainerCenter = ( container: ExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { if (!isArrowElement(container)) { return { @@ -709,18 +658,23 @@ export const getContainerCenter = ( y: container.y + container.height / 2, }; } - const points = LinearElementEditor.getPointsGlobalCoordinates(container); + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); if (points.length % 2 === 1) { const index = Math.floor(container.points.length / 2); const midPoint = LinearElementEditor.getPointGlobalCoordinates( container, container.points[index], + elementsMap, ); return { x: midPoint[0], y: midPoint[1] }; } const index = container.points.length / 2 - 1; let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints( container, + elementsMap, appState, )[index]; if (!midSegmentMidpoint) { @@ -729,6 +683,7 @@ export const getContainerCenter = ( points[index], points[index + 1], index + 1, + elementsMap, ); } return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; @@ -754,48 +709,38 @@ export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { }; }; -export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { - const container = getContainerElement(textElement); +export const getTextElementAngle = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, +) => { if (!container || isArrowElement(container)) { return textElement.angle; } return container.angle; }; -export const getBoundTextElementOffset = ( - boundTextElement: ExcalidrawTextElement | null, -) => { - const container = getContainerElement(boundTextElement); - if (!container || !boundTextElement) { - return 0; - } - if (isArrowElement(container)) { - return BOUND_TEXT_PADDING * 8; - } - - return BOUND_TEXT_PADDING; -}; - export const getBoundTextElementPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ) => { if (isArrowElement(container)) { return LinearElementEditor.getBoundTextElementPosition( container, boundTextElement, + elementsMap, ); } }; export const shouldAllowVerticalAlign = ( selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, ) => { return selectedElements.some((element) => { - const hasBoundContainer = isBoundToContainer(element); - if (hasBoundContainer) { - const container = getContainerElement(element); - if (isTextElement(element) && isArrowElement(container)) { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { return false; } return true; @@ -806,12 +751,12 @@ export const shouldAllowVerticalAlign = ( export const suppportsHorizontalAlign = ( selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, ) => { return selectedElements.some((element) => { - const hasBoundContainer = isBoundToContainer(element); - if (hasBoundContainer) { - const container = getContainerElement(element); - if (isTextElement(element) && isArrowElement(container)) { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { return false; } return true; @@ -821,45 +766,6 @@ export const suppportsHorizontalAlign = ( }); }; -export const getTextBindableContainerAtPosition = ( - elements: readonly ExcalidrawElement[], - appState: AppState, - x: number, - y: number, -): ExcalidrawTextContainer | null => { - const selectedElements = getSelectedElements(elements, appState); - if (selectedElements.length === 1) { - return isTextBindableContainer(selectedElements[0], false) - ? selectedElements[0] - : null; - } - let hitElement = null; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - for (let index = elements.length - 1; index >= 0; --index) { - if (elements[index].isDeleted) { - continue; - } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); - if ( - isArrowElement(elements[index]) && - isHittingElementNotConsideringBoundingBox( - elements[index], - appState, - null, - [x, y], - ) - ) { - hitElement = elements[index]; - break; - } else if (x1 < x && x < x2 && y1 < y && y < y2) { - hitElement = elements[index]; - break; - } - } - - return isTextBindableContainer(hitElement, false) ? hitElement : null; -}; - const VALID_CONTAINER_TYPES = new Set([ "rectangle", "ellipse", @@ -892,9 +798,7 @@ export const computeContainerDimensionForBoundText = ( export const getBoundTextMaxWidth = ( container: ExcalidrawElement, - boundTextElement: ExcalidrawTextElement | null = getBoundTextElement( - container, - ), + boundTextElement: ExcalidrawTextElement | null, ) => { const { width } = container; if (isArrowElement(container)) { @@ -969,13 +873,57 @@ const DEFAULT_LINE_HEIGHT = { // ~1.25 is the average for Virgil in WebKit and Blink. // Gecko (FF) uses ~1.28. [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], - // ~1.15 is the average for Virgil in WebKit and Blink. - // Gecko if all over the place. + // ~1.15 is the average for Helvetica in WebKit and Blink. [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], - // ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too + // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], }; +/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */ +type sTypoAscender = number & MakeBrand<"sTypoAscender">; + +/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */ +type sTypoDescender = number & MakeBrand<"sTypoDescender">; + +/** head.unitsPerEm, usually either 1000 or 2048 */ +type unitsPerEm = number & MakeBrand<"unitsPerEm">; + +/** + * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html. + * For custom fonts, read these metrics from OS/2 table and extend this object. + * + * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first. + */ +export const FONT_METRICS: Record< + number, + { + unitsPerEm: number; + ascender: sTypoAscender; + descender: sTypoDescender; + } +> = { + [FONT_FAMILY.Virgil]: { + unitsPerEm: 1000 as unitsPerEm, + ascender: 886 as sTypoAscender, + descender: -374 as sTypoDescender, + }, + [FONT_FAMILY.Helvetica]: { + unitsPerEm: 2048 as unitsPerEm, + ascender: 1577 as sTypoAscender, + descender: -471 as sTypoDescender, + }, + [FONT_FAMILY.Cascadia]: { + unitsPerEm: 2048 as unitsPerEm, + ascender: 1977 as sTypoAscender, + descender: -480 as sTypoDescender, + }, + [FONT_FAMILY.Assistant]: { + unitsPerEm: 1000 as unitsPerEm, + ascender: 1021 as sTypoAscender, + descender: -287 as sTypoDescender, + }, +}; + export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { if (fontFamily in DEFAULT_LINE_HEIGHT) { return DEFAULT_LINE_HEIGHT[fontFamily]; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index e6b0aa0b2..2d38b8213 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -17,7 +17,7 @@ import { } from "./types"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; -import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; +import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; // Unmount ReactDOM from root @@ -1454,7 +1454,7 @@ describe("textWysiwyg", () => { strokeWidth: 2, type: "rectangle", updated: 1, - version: 1, + version: 2, width: 610, x: 15, y: 25, diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 52f89e0b9..7dfdbc615 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -11,13 +11,12 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, isSafari } from "../constants"; +import { CLASSES } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, ExcalidrawTextElement, - ExcalidrawTextContainer, } from "./types"; import { AppState } from "../types"; import { bumpVersion, mutateElement } from "./mutateElement"; @@ -32,8 +31,8 @@ import { getBoundTextMaxHeight, getBoundTextMaxWidth, computeContainerDimensionForBoundText, - detectLineHeight, computeBoundTextPosition, + getBoundTextElement, } from "./textElement"; import { actionDecreaseFontSize, @@ -43,6 +42,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; +import { + originalContainerCache, + updateOriginalContainerCache, +} from "./containerCache"; const getTransform = ( width: number, @@ -65,38 +68,6 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; -const originalContainerCache: { - [id: ExcalidrawTextContainer["id"]]: - | { - height: ExcalidrawTextContainer["height"]; - } - | undefined; -} = {}; - -export const updateOriginalContainerCache = ( - id: ExcalidrawTextContainer["id"], - height: ExcalidrawTextContainer["height"], -) => { - const data = - originalContainerCache[id] || (originalContainerCache[id] = { height }); - data.height = height; - return data; -}; - -export const resetOriginalContainerCache = ( - id: ExcalidrawTextContainer["id"], -) => { - if (originalContainerCache[id]) { - delete originalContainerCache[id]; - } -}; - -export const getOriginalContainerHeightFromCache = ( - id: ExcalidrawTextContainer["id"], -) => { - return originalContainerCache[id]?.height ?? null; -}; - export const textWysiwyg = ({ id, onChange, @@ -149,11 +120,14 @@ export const textWysiwyg = ({ return; } const { textAlign, verticalAlign } = updatedTextElement; - + const elementsMap = app.scene.getNonDeletedElementsMap(); if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; - const container = getContainerElement(updatedTextElement); + const container = getContainerElement( + updatedTextElement, + app.scene.getNonDeletedElementsMap(), + ); let maxWidth = updatedTextElement.width; let maxHeight = updatedTextElement.height; @@ -168,6 +142,7 @@ export const textWysiwyg = ({ LinearElementEditor.getBoundTextElementPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, ); coordX = boundTextCoords.x; coordY = boundTextCoords.y; @@ -193,7 +168,8 @@ export const textWysiwyg = ({ } } - maxWidth = getBoundTextMaxWidth(container); + maxWidth = getBoundTextMaxWidth(container, updatedTextElement); + maxHeight = getBoundTextMaxHeight( container, updatedTextElement as ExcalidrawTextElementWithContainer, @@ -224,6 +200,7 @@ export const textWysiwyg = ({ const { y } = computeBoundTextPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, ); coordY = y; } @@ -249,18 +226,6 @@ export const textWysiwyg = ({ if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; textElementWidth = Math.min(textElementWidth, maxWidth); - } else { - textElementWidth += 0.5; - } - - let lineHeight = updatedTextElement.lineHeight; - - // In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size - if (isSafari) { - lineHeight = detectLineHeight({ - ...updatedTextElement, - fontSize: Math.round(updatedTextElement.fontSize), - }); } // Make sure text editor height doesn't go beyond viewport @@ -269,7 +234,7 @@ export const textWysiwyg = ({ Object.assign(editable.style, { font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ - lineHeight, + lineHeight: updatedTextElement.lineHeight, width: `${textElementWidth}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, @@ -277,7 +242,7 @@ export const textWysiwyg = ({ transform: getTransform( textElementWidth, textElementHeight, - getTextElementAngle(updatedTextElement), + getTextElementAngle(updatedTextElement, container), appState, maxWidth, editorMaxHeight, @@ -348,17 +313,24 @@ export const textWysiwyg = ({ if (!data) { return; } - const container = getContainerElement(element); + const container = getContainerElement( + element, + app.scene.getNonDeletedElementsMap(), + ); const font = getFontString({ fontSize: app.state.currentItemFontSize, fontFamily: app.state.currentItemFontFamily, }); if (container) { + const boundTextElement = getBoundTextElement( + container, + app.scene.getNonDeletedElementsMap(), + ); const wrappedText = wrapText( `${editable.value}${data}`, font, - getBoundTextMaxWidth(container), + getBoundTextMaxWidth(container, boundTextElement), ); const width = getTextWidth(wrappedText, font); editable.style.width = `${width}px`; @@ -528,7 +500,10 @@ export const textWysiwyg = ({ return; } let text = editable.value; - const container = getContainerElement(updateElement); + const container = getContainerElement( + updateElement, + app.scene.getNonDeletedElementsMap(), + ); if (container) { text = updateElement.text; @@ -555,7 +530,11 @@ export const textWysiwyg = ({ ), }); } - redrawTextBoundingBox(updateElement, container); + redrawTextBoundingBox( + updateElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } onSubmit({ diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 00ebfacfd..aee745530 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -1,4 +1,5 @@ import { + ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, PointerType, @@ -9,7 +10,7 @@ import { rotate } from "../math"; import { InteractiveCanvasAppState, Zoom } from "../types"; import { isTextElement } from "."; import { isFrameLikeElement, isLinearElement } from "./typeChecks"; -import { DEFAULT_SPACING } from "../renderer/renderScene"; +import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants"; export type TransformHandleDirection = | "n" @@ -106,7 +107,8 @@ export const getTransformHandlesFromCoords = ( const width = x2 - x1; const height = y2 - y1; const dashedLineMargin = margin / zoom.value; - const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value); + const centeringOffset = + (size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value); const transformHandles: TransformHandles = { nw: omitSides.nw @@ -229,6 +231,8 @@ export const getTransformHandlesFromCoords = ( export const getTransformHandles = ( element: ExcalidrawElement, zoom: Zoom, + elementsMap: ElementsMap, + pointerType: PointerType = "mouse", ): TransformHandles => { // so that when locked element is selected (especially when you toggle lock @@ -263,10 +267,10 @@ export const getTransformHandles = ( }; } const dashedLineMargin = isLinearElement(element) - ? DEFAULT_SPACING + 8 - : DEFAULT_SPACING; + ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 + : DEFAULT_TRANSFORM_HANDLE_SPACING; return getTransformHandlesFromCoords( - getElementAbsoluteCoords(element, true), + getElementAbsoluteCoords(element, elementsMap, true), element.angle, zoom, pointerType, diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index ef1bcd3db..7193e251b 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -214,7 +214,10 @@ export const isBoundToContainer = ( }; export const isUsingAdaptiveRadius = (type: string) => - type === "rectangle" || type === "embeddable" || type === "iframe"; + type === "rectangle" || + type === "embeddable" || + type === "iframe" || + type === "image"; export const isUsingProportionalRadius = (type: string) => type === "line" || type === "arrow" || type === "diamond"; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 38be1bda6..2ee9a12b0 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -6,7 +6,7 @@ import { THEME, VERTICAL_ALIGN, } from "../constants"; -import { MarkNonNullable, ValueOf } from "../utility-types"; +import { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types"; import { MagicCacheData } from "../data/magic"; export type ChartType = "bar" | "line"; @@ -24,6 +24,7 @@ export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN]; type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN; export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys]; +export type FractionalIndex = string & { _brand: "franctionalIndex" }; type _ExcalidrawElementBase = Readonly<{ id: string; @@ -50,6 +51,11 @@ type _ExcalidrawElementBase = Readonly<{ Used for deterministic reconciliation of updates during collaboration, in case the versions (see above) are identical. */ versionNonce: number; + /** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing. + Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo. + Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`. + Could be null, i.e. for new elements which were not yet assigned to the scene. */ + index: FractionalIndex | null; isDeleted: boolean; /** List of groups the element belongs to. Ordered from deepest to shallowest. */ @@ -88,14 +94,6 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase & Readonly<{ type: "embeddable"; - /** - * indicates whether the embeddable src (url) has been validated for rendering. - * null value indicates that the validation is pending. We reset the - * value on each restore (or url change) so that we can guarantee - * the validation came from a trusted source (the editor). Also because we - * may not have access to host-app supplied url validator during restore. - */ - validated: boolean | null; }>; export type ExcalidrawIframeElement = _ExcalidrawElementBase & @@ -112,7 +110,8 @@ export type ExcalidrawIframeLikeElement = export type IframeData = | { intrinsicSize: { w: number; h: number }; - warning?: string; + error?: Error; + sandbox?: { allowSameOrigin?: boolean }; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } @@ -172,6 +171,12 @@ export type ExcalidrawElement = | ExcalidrawIframeElement | ExcalidrawEmbeddableElement; +export type Ordered = TElement & { + index: FractionalIndex; +}; + +export type OrderedExcalidrawElement = Ordered; + export type NonDeleted = TElement & { isDeleted: boolean; }; @@ -184,7 +189,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & fontSize: number; fontFamily: FontFamilyValues; text: string; - baseline: number; textAlign: TextAlign; verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; @@ -262,3 +266,44 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type FileId = string & { _brand: "FileId" }; export type ExcalidrawElementType = ExcalidrawElement["type"]; + +/** + * Map of excalidraw elements. + * Unspecified whether deleted or non-deleted. + * Can be a subset of Scene elements. + */ +export type ElementsMap = Map; + +/** + * Map of non-deleted elements. + * Can be a subset of Scene elements. + */ +export type NonDeletedElementsMap = Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement +> & + MakeBrand<"NonDeletedElementsMap">; + +/** + * Map of all excalidraw Scene elements, including deleted. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type SceneElementsMap = Map< + ExcalidrawElement["id"], + Ordered +> & + MakeBrand<"SceneElementsMap">; + +/** + * Map of all non-deleted Scene elements. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type NonDeletedSceneElementsMap = Map< + ExcalidrawElement["id"], + Ordered +> & + MakeBrand<"NonDeletedSceneElementsMap">; + +export type ElementsMapOrArray = + | readonly ExcalidrawElement[] + | Readonly; diff --git a/packages/excalidraw/emitter.ts b/packages/excalidraw/emitter.ts index 5b1cdd0a7..98e97ad46 100644 --- a/packages/excalidraw/emitter.ts +++ b/packages/excalidraw/emitter.ts @@ -1,21 +1,16 @@ +import { UnsubscribeCallback } from "./types"; + type Subscriber = (...payload: T) => void; export class Emitter { public subscribers: Subscriber[] = []; - public value: T | undefined; - private updateOnChangeOnly: boolean; - - constructor(opts?: { initialState?: T; updateOnChangeOnly?: boolean }) { - this.updateOnChangeOnly = opts?.updateOnChangeOnly ?? false; - this.value = opts?.initialState; - } /** * Attaches subscriber * * @returns unsubscribe function */ - on(...handlers: Subscriber[] | Subscriber[][]) { + on(...handlers: Subscriber[] | Subscriber[][]): UnsubscribeCallback { const _handlers = handlers .flat() .filter((item) => typeof item === "function"); @@ -25,6 +20,17 @@ export class Emitter { return () => this.off(_handlers); } + once(...handlers: Subscriber[] | Subscriber[][]): UnsubscribeCallback { + const _handlers = handlers + .flat() + .filter((item) => typeof item === "function"); + + _handlers.push(() => detach()); + + const detach = this.on(..._handlers); + return detach; + } + off(...handlers: Subscriber[] | Subscriber[][]) { const _handlers = handlers.flat(); this.subscribers = this.subscribers.filter( @@ -32,16 +38,14 @@ export class Emitter { ); } - trigger(...payload: T): any[] { - if (this.updateOnChangeOnly && this.value === payload) { - return []; + trigger(...payload: T) { + for (const handler of this.subscribers) { + handler(...payload); } - this.value = payload; - return this.subscribers.map((handler) => handler(...payload)); + return this; } - destroy() { + clear() { this.subscribers = []; - this.value = undefined; } } diff --git a/packages/excalidraw/entry.js b/packages/excalidraw/entry.js deleted file mode 100644 index bf1029b45..000000000 --- a/packages/excalidraw/entry.js +++ /dev/null @@ -1,7 +0,0 @@ -import "./publicPath"; -import polyfill from "./polyfill"; - -import "../../public/fonts.css"; - -polyfill(); -export * from "./index"; diff --git a/packages/excalidraw/env.js b/packages/excalidraw/env.cjs similarity index 100% rename from packages/excalidraw/env.js rename to packages/excalidraw/env.cjs diff --git a/packages/excalidraw/errors.ts b/packages/excalidraw/errors.ts index 4df403496..705ba7a6f 100644 --- a/packages/excalidraw/errors.ts +++ b/packages/excalidraw/errors.ts @@ -32,3 +32,7 @@ export class ImageSceneDataError extends Error { this.code = code; } } + +export class InvalidFractionalIndexError extends Error { + public code = "ELEMENT_HAS_INVALID_INDEX" as const; +} diff --git a/packages/excalidraw/example/MobileFooter.tsx b/packages/excalidraw/example/MobileFooter.tsx deleted file mode 100644 index f26fe587f..000000000 --- a/packages/excalidraw/example/MobileFooter.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ExcalidrawImperativeAPI } from "../types"; -import CustomFooter from "./CustomFooter"; -const { useDevice, Footer } = window.ExcalidrawLib; - -const MobileFooter = ({ - excalidrawAPI, -}: { - excalidrawAPI: ExcalidrawImperativeAPI; -}) => { - const device = useDevice(); - if (device.editor.isMobile) { - return ( -
- -
- ); - } - return null; -}; -export default MobileFooter; diff --git a/packages/excalidraw/example/index.tsx b/packages/excalidraw/example/index.tsx deleted file mode 100644 index 0f3bad30f..000000000 --- a/packages/excalidraw/example/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; - -import App from "./App"; - -const rootElement = document.getElementById("root")!; -const root = createRoot(rootElement); - -root.render( - - {}} - /> - , -); diff --git a/packages/excalidraw/fractionalIndex.ts b/packages/excalidraw/fractionalIndex.ts new file mode 100644 index 000000000..ccda1d365 --- /dev/null +++ b/packages/excalidraw/fractionalIndex.ts @@ -0,0 +1,348 @@ +import { generateNKeysBetween } from "fractional-indexing"; +import { mutateElement } from "./element/mutateElement"; +import { + ExcalidrawElement, + FractionalIndex, + OrderedExcalidrawElement, +} from "./element/types"; +import { InvalidFractionalIndexError } from "./errors"; + +/** + * Envisioned relation between array order and fractional indices: + * + * 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation. + * - it's undesirable to to perform reorder for each related operation, thefeore it's necessary to cache the order defined by fractional indices into an ordered data structure + * - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps) + * - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc. + * - it's necessary to always keep the fractional indices in sync with the array order + * - elements with invalid indices should be detected and synced, without altering the already valid indices + * + * 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated. + * - as the fractional indices are encoded as part of the elements, it opens up possibilties for incremental-like APIs + * - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & undo/redo + * - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits, + * as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order + */ + +/** + * Ensure that all elements have valid fractional indices. + * + * @throws `InvalidFractionalIndexError` if invalid index is detected. + */ +export const validateFractionalIndices = ( + indices: (ExcalidrawElement["index"] | undefined)[], +) => { + for (const [i, index] of indices.entries()) { + const predecessorIndex = indices[i - 1]; + const successorIndex = indices[i + 1]; + + if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) { + throw new InvalidFractionalIndexError( + `Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`, + ); + } + } +}; + +/** + * Order the elements based on the fractional indices. + * - when fractional indices are identical, break the tie based on the element id + * - when there is no fractional index in one of the elements, respect the order of the array + */ +export const orderByFractionalIndex = ( + elements: OrderedExcalidrawElement[], +) => { + return elements.sort((a, b) => { + // in case the indices are not the defined at runtime + if (isOrderedElement(a) && isOrderedElement(b)) { + if (a.index < b.index) { + return -1; + } else if (a.index > b.index) { + return 1; + } + + // break ties based on the element id + return a.id < b.id ? -1 : 1; + } + + // defensively keep the array order + return 1; + }); +}; + +/** + * Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements. + * If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`. + */ +export const syncMovedIndices = ( + elements: readonly ExcalidrawElement[], + movedElements: Map, +): OrderedExcalidrawElement[] => { + try { + const indicesGroups = getMovedIndicesGroups(elements, movedElements); + + // try generatating indices, throws on invalid movedElements + const elementsUpdates = generateIndices(elements, indicesGroups); + + // ensure next indices are valid before mutation, throws on invalid ones + validateFractionalIndices( + elements.map((x) => elementsUpdates.get(x)?.index || x.index), + ); + + // split mutation so we don't end up in an incosistent state + for (const [element, update] of elementsUpdates) { + mutateElement(element, update, false); + } + } catch (e) { + // fallback to default sync + syncInvalidIndices(elements); + } + + return elements as OrderedExcalidrawElement[]; +}; + +/** + * Synchronizes all invalid fractional indices with the array order by mutating passed elements. + * + * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself. + */ +export const syncInvalidIndices = ( + elements: readonly ExcalidrawElement[], +): OrderedExcalidrawElement[] => { + const indicesGroups = getInvalidIndicesGroups(elements); + const elementsUpdates = generateIndices(elements, indicesGroups); + + for (const [element, update] of elementsUpdates) { + mutateElement(element, update, false); + } + + return elements as OrderedExcalidrawElement[]; +}; + +/** + * Get contiguous groups of indices of passed moved elements. + * + * NOTE: First and last elements within the groups are indices of lower and upper bounds. + */ +const getMovedIndicesGroups = ( + elements: readonly ExcalidrawElement[], + movedElements: Map, +) => { + const indicesGroups: number[][] = []; + + let i = 0; + + while (i < elements.length) { + if ( + movedElements.has(elements[i].id) && + !isValidFractionalIndex( + elements[i]?.index, + elements[i - 1]?.index, + elements[i + 1]?.index, + ) + ) { + const indicesGroup = [i - 1, i]; // push the lower bound index as the first item + + while (++i < elements.length) { + if ( + !( + movedElements.has(elements[i].id) && + !isValidFractionalIndex( + elements[i]?.index, + elements[i - 1]?.index, + elements[i + 1]?.index, + ) + ) + ) { + break; + } + + indicesGroup.push(i); + } + + indicesGroup.push(i); // push the upper bound index as the last item + indicesGroups.push(indicesGroup); + } else { + i++; + } + } + + return indicesGroups; +}; + +/** + * Gets contiguous groups of all invalid indices automatically detected inside the elements array. + * + * WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds! + */ +const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => { + const indicesGroups: number[][] = []; + + // once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf. + let lowerBound: ExcalidrawElement["index"] | undefined = undefined; + let upperBound: ExcalidrawElement["index"] | undefined = undefined; + let lowerBoundIndex: number = -1; + let upperBoundIndex: number = 0; + + /** @returns maybe valid lowerBound */ + const getLowerBound = ( + index: number, + ): [ExcalidrawElement["index"] | undefined, number] => { + const lowerBound = elements[lowerBoundIndex] + ? elements[lowerBoundIndex].index + : undefined; + + // we are already iterating left to right, therefore there is no need for additional looping + const candidate = elements[index - 1]?.index; + + if ( + (!lowerBound && candidate) || // first lowerBound + (lowerBound && candidate && candidate > lowerBound) // next lowerBound + ) { + // WARN: candidate's index could be higher or same as the current element's index + return [candidate, index - 1]; + } + + // cache hit! take the last lower bound + return [lowerBound, lowerBoundIndex]; + }; + + /** @returns always valid upperBound */ + const getUpperBound = ( + index: number, + ): [ExcalidrawElement["index"] | undefined, number] => { + const upperBound = elements[upperBoundIndex] + ? elements[upperBoundIndex].index + : undefined; + + // cache hit! don't let it find the upper bound again + if (upperBound && index < upperBoundIndex) { + return [upperBound, upperBoundIndex]; + } + + // set the current upperBoundIndex as the starting point + let i = upperBoundIndex; + while (++i < elements.length) { + const candidate = elements[i]?.index; + + if ( + (!upperBound && candidate) || // first upperBound + (upperBound && candidate && candidate > upperBound) // next upperBound + ) { + return [candidate, i]; + } + } + + // we reached the end, sky is the limit + return [undefined, i]; + }; + + let i = 0; + + while (i < elements.length) { + const current = elements[i].index; + [lowerBound, lowerBoundIndex] = getLowerBound(i); + [upperBound, upperBoundIndex] = getUpperBound(i); + + if (!isValidFractionalIndex(current, lowerBound, upperBound)) { + // push the lower bound index as the first item + const indicesGroup = [lowerBoundIndex, i]; + + while (++i < elements.length) { + const current = elements[i].index; + const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i); + const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i); + + if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) { + break; + } + + // assign bounds only for the moved elements + [lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex]; + [upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex]; + + indicesGroup.push(i); + } + + // push the upper bound index as the last item + indicesGroup.push(upperBoundIndex); + indicesGroups.push(indicesGroup); + } else { + i++; + } + } + + return indicesGroups; +}; + +const isValidFractionalIndex = ( + index: ExcalidrawElement["index"] | undefined, + predecessor: ExcalidrawElement["index"] | undefined, + successor: ExcalidrawElement["index"] | undefined, +) => { + if (!index) { + return false; + } + + if (predecessor && successor) { + return predecessor < index && index < successor; + } + + if (!predecessor && successor) { + // first element + return index < successor; + } + + if (predecessor && !successor) { + // last element + return predecessor < index; + } + + // only element in the array + return !!index; +}; + +const generateIndices = ( + elements: readonly ExcalidrawElement[], + indicesGroups: number[][], +) => { + const elementsUpdates = new Map< + ExcalidrawElement, + { index: FractionalIndex } + >(); + + for (const indices of indicesGroups) { + const lowerBoundIndex = indices.shift()!; + const upperBoundIndex = indices.pop()!; + + const fractionalIndices = generateNKeysBetween( + elements[lowerBoundIndex]?.index, + elements[upperBoundIndex]?.index, + indices.length, + ) as FractionalIndex[]; + + for (let i = 0; i < indices.length; i++) { + const element = elements[indices[i]]; + + elementsUpdates.set(element, { + index: fractionalIndices[i], + }); + } + } + + return elementsUpdates; +}; + +const isOrderedElement = ( + element: ExcalidrawElement, +): element is OrderedExcalidrawElement => { + // for now it's sufficient whether the index is there + // meaning, the element was already ordered in the past + // meaning, it is not a newly inserted element, not an unrestored element, etc. + // it does not have to mean that the index itself is valid + if (element.index) { + return true; + } + + return false; +}; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 5b2186533..c2e7aa162 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -4,6 +4,8 @@ import { isTextElement, } from "./element"; import { + ElementsMap, + ElementsMapOrArray, ExcalidrawElement, ExcalidrawFrameLikeElement, NonDeleted, @@ -19,14 +21,15 @@ import { mutateElement } from "./element/mutateElement"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; -import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; +import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; -import { doLineSegmentsIntersect } from "../utils"; +import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; +import { ReadonlySetLike } from "./utility-types"; // --------------------------- Frame State ------------------------------------ export const bindElementsToFramesAfterDuplication = ( - nextElements: ExcalidrawElement[], + nextElements: readonly ExcalidrawElement[], oldElements: readonly ExcalidrawElement[], oldIdToDuplicatedId: Map, ) => { @@ -59,10 +62,11 @@ export const bindElementsToFramesAfterDuplication = ( export function isElementIntersectingFrame( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) { - const frameLineSegments = getElementLineSegments(frame); + const frameLineSegments = getElementLineSegments(frame, elementsMap); - const elementLineSegments = getElementLineSegments(element); + const elementLineSegments = getElementLineSegments(element, elementsMap); const intersecting = frameLineSegments.some((frameLineSegment) => elementLineSegments.some((elementLineSegment) => @@ -76,9 +80,10 @@ export function isElementIntersectingFrame( export const getElementsCompletelyInFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => omitGroupsContainingFrameLikes( - getElementsWithinSelection(elements, frame, false), + getElementsWithinSelection(elements, frame, elementsMap, false), ).filter( (element) => (!isFrameLikeElement(element) && !element.frameId) || @@ -89,8 +94,9 @@ export const isElementContainingFrame = ( elements: readonly ExcalidrawElement[], element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - return getElementsWithinSelection(elements, element).some( + return getElementsWithinSelection(elements, element, elementsMap).some( (e) => e.id === frame.id, ); }; @@ -98,34 +104,43 @@ export const isElementContainingFrame = ( export const getElementsIntersectingFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, -) => elements.filter((element) => isElementIntersectingFrame(element, frame)); +) => { + const elementsMap = arrayToMap(elements); + return elements.filter((element) => + isElementIntersectingFrame(element, frame, elementsMap), + ); +}; export const elementsAreInFrameBounds = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(frame); + const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords( + frame, + elementsMap, + ); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(elements); return ( - selectionX1 <= elementX1 && - selectionY1 <= elementY1 && - selectionX2 >= elementX2 && - selectionY2 >= elementY2 + frameX1 <= elementX1 && + frameY1 <= elementY1 && + frameX2 >= elementX2 && + frameY2 >= elementY2 ); }; export const elementOverlapsWithFrame = ( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { return ( - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame) || - isElementContainingFrame([frame], element, frame) + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap) || + isElementContainingFrame([frame], element, frame, elementsMap) ); }; @@ -135,8 +150,9 @@ export const isCursorInFrame = ( y: number; }, frame: NonDeleted, + elementsMap: ElementsMap, ) => { - const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame); + const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap); return isPointWithinBounds( [fx1, fy1], @@ -150,6 +166,7 @@ export const groupsAreAtLeastIntersectingTheFrame = ( groupIds: readonly string[], frame: ExcalidrawFrameLikeElement, ) => { + const elementsMap = arrayToMap(elements); const elementsInGroup = groupIds.flatMap((groupId) => getElementsInGroup(elements, groupId), ); @@ -160,8 +177,8 @@ export const groupsAreAtLeastIntersectingTheFrame = ( return !!elementsInGroup.find( (element) => - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame), + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap), ); }; @@ -170,6 +187,7 @@ export const groupsAreCompletelyOutOfFrame = ( groupIds: readonly string[], frame: ExcalidrawFrameLikeElement, ) => { + const elementsMap = arrayToMap(elements); const elementsInGroup = groupIds.flatMap((groupId) => getElementsInGroup(elements, groupId), ); @@ -181,8 +199,8 @@ export const groupsAreCompletelyOutOfFrame = ( return ( elementsInGroup.find( (element) => - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame), + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap), ) === undefined ); }; @@ -209,9 +227,17 @@ export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => { }; export const getFrameChildren = ( - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMapOrArray, frameId: string, -) => allElements.filter((element) => element.frameId === frameId); +) => { + const frameChildren: ExcalidrawElement[] = []; + for (const element of allElements.values()) { + if (element.frameId === frameId) { + frameChildren.push(element); + } + } + return frameChildren; +}; export const getFrameLikeElements = ( allElements: ExcalidrawElementsIncludingDeleted, @@ -245,14 +271,15 @@ export const getElementsInResizingFrame = ( allElements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameLikeElement, appState: AppState, + elementsMap: ElementsMap, ): ExcalidrawElement[] => { const prevElementsInFrame = getFrameChildren(allElements, frame.id); const nextElementsInFrame = new Set(prevElementsInFrame); const elementsCompletelyInFrame = new Set([ - ...getElementsCompletelyInFrame(allElements, frame), + ...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...prevElementsInFrame.filter((element) => - isElementContainingFrame(allElements, element, frame), + isElementContainingFrame(allElements, element, frame, elementsMap), ), ]); @@ -270,7 +297,7 @@ export const getElementsInResizingFrame = ( ); for (const element of elementsNotCompletelyInFrame) { - if (!isElementIntersectingFrame(element, frame)) { + if (!isElementIntersectingFrame(element, frame, elementsMap)) { if (element.groupIds.length === 0) { nextElementsInFrame.delete(element); } @@ -321,7 +348,7 @@ export const getElementsInResizingFrame = ( if (isSelected) { const elementsInGroup = getElementsInGroup(allElements, id); - if (elementsAreInFrameBounds(elementsInGroup, frame)) { + if (elementsAreInFrameBounds(elementsInGroup, frame, elementsMap)) { for (const element of elementsInGroup) { nextElementsInFrame.add(element); } @@ -335,77 +362,134 @@ export const getElementsInResizingFrame = ( }; export const getElementsInNewFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, + elements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { return omitGroupsContainingFrameLikes( - allElements, - getElementsCompletelyInFrame(allElements, frame), + elements, + getElementsCompletelyInFrame(elements, frame, elementsMap), ); }; export const getContainingFrame = ( element: ExcalidrawElement, - /** - * Optionally an elements map, in case the elements aren't in the Scene yet. - * Takes precedence over Scene elements, even if the element exists - * in Scene elements and not the supplied elements map. - */ - elementsMap?: Map, + elementsMap: ElementsMap, ) => { - if (element.frameId) { - if (elementsMap) { - return (elementsMap.get(element.frameId) || - null) as null | ExcalidrawFrameLikeElement; - } - return ( - (Scene.getScene(element)?.getElement( - element.frameId, - ) as ExcalidrawFrameLikeElement) || null - ); + if (!element.frameId) { + return null; } - return null; + return (elementsMap.get(element.frameId) || + null) as null | ExcalidrawFrameLikeElement; }; // --------------------------- Frame Operations ------------------------------- +/** */ +export const filterElementsEligibleAsFrameChildren = ( + elements: readonly ExcalidrawElement[], + frame: ExcalidrawFrameLikeElement, +) => { + const otherFrames = new Set(); + const elementsMap = arrayToMap(elements); + elements = omitGroupsContainingFrameLikes(elements); + + for (const element of elements) { + if (isFrameLikeElement(element) && element.id !== frame.id) { + otherFrames.add(element.id); + } + } + + const processedGroups = new Set(); + + const eligibleElements: ExcalidrawElement[] = []; + + for (const element of elements) { + // don't add frames or their children + if ( + isFrameLikeElement(element) || + (element.frameId && otherFrames.has(element.frameId)) + ) { + continue; + } + + if (element.groupIds.length) { + const shallowestGroupId = element.groupIds.at(-1)!; + if (!processedGroups.has(shallowestGroupId)) { + processedGroups.add(shallowestGroupId); + const groupElements = getElementsInGroup(elements, shallowestGroupId); + if ( + groupElements.some((el) => + elementOverlapsWithFrame(el, frame, elementsMap), + ) + ) { + for (const child of groupElements) { + eligibleElements.push(child); + } + } + } + } else { + const overlaps = elementOverlapsWithFrame(element, frame, elementsMap); + if (overlaps) { + eligibleElements.push(element); + } + } + } + + return eligibleElements; +}; + /** * Retains (or repairs for target frame) the ordering invriant where children * elements come right before the parent frame: * [el, el, child, child, frame, el] + * + * @returns mutated allElements (same data structure) */ -export const addElementsToFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const addElementsToFrame = ( + allElements: T, elementsToAdd: NonDeletedExcalidrawElement[], frame: ExcalidrawFrameLikeElement, -) => { - const { currTargetFrameChildrenMap } = allElements.reduce( - (acc, element, index) => { - if (element.frameId === frame.id) { - acc.currTargetFrameChildrenMap.set(element.id, true); - } - return acc; - }, - { - currTargetFrameChildrenMap: new Map(), - }, - ); +): T => { + const elementsMap = arrayToMap(allElements); + const currTargetFrameChildrenMap = new Map(); + for (const element of allElements.values()) { + if (element.frameId === frame.id) { + currTargetFrameChildrenMap.set(element.id, true); + } + } const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); const finalElementsToAdd: ExcalidrawElement[] = []; + const otherFrames = new Set(); + + for (const element of elementsToAdd) { + if (isFrameLikeElement(element) && element.id !== frame.id) { + otherFrames.add(element.id); + } + } + // - add bound text elements if not already in the array // - filter out elements that are already in the frame for (const element of omitGroupsContainingFrameLikes( allElements, elementsToAdd, )) { + // don't add frames or their children + if ( + isFrameLikeElement(element) || + (element.frameId && otherFrames.has(element.frameId)) + ) { + continue; + } + if (!currTargetFrameChildrenMap.has(element.id)) { finalElementsToAdd.push(element); } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if ( boundTextElement && !suppliedElementsToAddSet.has(boundTextElement.id) && @@ -424,13 +508,13 @@ export const addElementsToFrame = ( false, ); } - return allElements.slice(); + + return allElements; }; export const removeElementsFromFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, - elementsToRemove: NonDeletedExcalidrawElement[], - appState: AppState, + elementsToRemove: ReadonlySetLike, + elementsMap: ElementsMap, ) => { const _elementsToRemove = new Map< ExcalidrawElement["id"], @@ -449,7 +533,7 @@ export const removeElementsFromFrame = ( const arr = toRemoveElementsByFrame.get(element.frameId) || []; arr.push(element); - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { _elementsToRemove.set(boundTextElement.id, boundTextElement); arr.push(boundTextElement); @@ -468,35 +552,35 @@ export const removeElementsFromFrame = ( false, ); } - - return allElements.slice(); }; -export const removeAllElementsFromFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const removeAllElementsFromFrame = ( + allElements: readonly T[], frame: ExcalidrawFrameLikeElement, - appState: AppState, ) => { const elementsInFrame = getFrameChildren(allElements, frame.id); - return removeElementsFromFrame(allElements, elementsInFrame, appState); + removeElementsFromFrame(elementsInFrame, arrayToMap(allElements)); + return allElements; }; -export const replaceAllElementsInFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const replaceAllElementsInFrame = ( + allElements: readonly T[], nextElementsInFrame: ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, - appState: AppState, -) => { + app: AppClassProperties, +): T[] => { return addElementsToFrame( - removeAllElementsFromFrame(allElements, frame, appState), + removeAllElementsFromFrame(allElements, frame), nextElementsInFrame, frame, - ); + ).slice(); }; /** does not mutate elements, but returns new ones */ -export const updateFrameMembershipOfSelectedElements = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const updateFrameMembershipOfSelectedElements = < + T extends ElementsMapOrArray, +>( + allElements: T, appState: AppState, app: AppClassProperties, ) => { @@ -521,19 +605,22 @@ export const updateFrameMembershipOfSelectedElements = ( const elementsToRemove = new Set(); + const elementsMap = arrayToMap(allElements); + elementsToFilter.forEach((element) => { if ( element.frameId && !isFrameLikeElement(element) && - !isElementInFrame(element, allElements, appState) + !isElementInFrame(element, elementsMap, appState) ) { elementsToRemove.add(element); } }); - return elementsToRemove.size > 0 - ? removeElementsFromFrame(allElements, [...elementsToRemove], appState) - : allElements; + if (elementsToRemove.size > 0) { + removeElementsFromFrame(elementsToRemove, elementsMap); + } + return allElements; }; /** @@ -541,14 +628,16 @@ export const updateFrameMembershipOfSelectedElements = ( * anywhere in the group tree */ export const omitGroupsContainingFrameLikes = ( - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMapOrArray, /** subset of elements you want to filter. Optional perf optimization so we * don't have to filter all elements unnecessarily */ selectedElements?: readonly ExcalidrawElement[], ) => { const uniqueGroupIds = new Set(); - for (const el of selectedElements || allElements) { + const elements = selectedElements || allElements; + + for (const el of elements.values()) { const topMostGroupId = el.groupIds[el.groupIds.length - 1]; if (topMostGroupId) { uniqueGroupIds.add(topMostGroupId); @@ -566,9 +655,15 @@ export const omitGroupsContainingFrameLikes = ( } } - return (selectedElements || allElements).filter( - (el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]), - ); + const ret: ExcalidrawElement[] = []; + + for (const element of elements.values()) { + if (!rejectedGroupIds.has(element.groupIds[element.groupIds.length - 1])) { + ret.push(element); + } + } + + return ret; }; /** @@ -577,28 +672,29 @@ export const omitGroupsContainingFrameLikes = ( */ export const getTargetFrame = ( element: ExcalidrawElement, + elementsMap: ElementsMap, appState: StaticCanvasAppState, ) => { const _element = isTextElement(element) - ? getContainerElement(element) || element + ? getContainerElement(element, elementsMap) || element : element; return appState.selectedElementIds[_element.id] && appState.selectedElementsAreBeingDragged ? appState.frameToHighlight - : getContainingFrame(_element); + : getContainingFrame(_element, elementsMap); }; // TODO: this a huge bottleneck for large scenes, optimise // given an element, return if the element is in some frame export const isElementInFrame = ( element: ExcalidrawElement, - allElements: ExcalidrawElementsIncludingDeleted, + allElementsMap: ElementsMap, appState: StaticCanvasAppState, ) => { - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, allElementsMap, appState); const _element = isTextElement(element) - ? getContainerElement(element) || element + ? getContainerElement(element, allElementsMap) || element : element; if (frame) { @@ -614,16 +710,18 @@ export const isElementInFrame = ( } if (_element.groupIds.length === 0) { - return elementOverlapsWithFrame(_element, frame); + return elementOverlapsWithFrame(_element, frame, allElementsMap); } const allElementsInGroup = new Set( - _element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)), + _element.groupIds.flatMap((gid) => + getElementsInGroup(allElementsMap, gid), + ), ); if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { const selectedElements = new Set( - getSelectedElements(allElements, appState), + getSelectedElements(allElementsMap, appState), ); const editingGroupOverlapsFrame = appState.frameToHighlight !== null; @@ -644,7 +742,7 @@ export const isElementInFrame = ( } for (const elementInGroup of allElementsInGroup) { - if (elementOverlapsWithFrame(elementInGroup, frame)) { + if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) { return true; } } @@ -657,10 +755,26 @@ export const getFrameLikeTitle = ( element: ExcalidrawFrameLikeElement, frameIdx: number, ) => { - const existingName = element.name?.trim(); - if (existingName) { - return existingName; - } - // TODO name frames AI only is specific to AI frames - return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`; + // TODO name frames "AI" only if specific to AI frames + return element.name === null + ? isFrameElement(element) + ? `Frame ${frameIdx}` + : `AI Frame $${frameIdx}` + : element.name; +}; + +export const getElementsOverlappingFrame = ( + elements: readonly ExcalidrawElement[], + frame: ExcalidrawFrameLikeElement, +) => { + return ( + elementsOverlappingBBox({ + elements, + bounds: frame, + type: "overlap", + }) + // removes elements who are overlapping, but are in a different frame, + // and thus invisible in target frame + .filter((el) => !el.frameId || el.frameId === frame.id) + ); }; diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 76730c8de..49e5eac1c 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -1,16 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -interface Document { - fonts?: { - ready?: Promise; - check?: (font: string, text?: string) => boolean; - load?: (font: string, text?: string) => Promise; - addEventListener?( - type: "loading" | "loadingdone" | "loadingerror", - listener: (this: Document, ev: Event) => any, - ): void; - }; -} - interface Window { ClipboardItem: any; __EXCALIDRAW_SHA__: string | undefined; diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts index dd5512ba1..f8c0eddb9 100644 --- a/packages/excalidraw/groups.ts +++ b/packages/excalidraw/groups.ts @@ -3,6 +3,8 @@ import { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement, + ElementsMapOrArray, + ElementsMap, } from "./element/types"; import { AppClassProperties, @@ -270,9 +272,17 @@ export const isElementInGroup = (element: ExcalidrawElement, groupId: string) => element.groupIds.includes(groupId); export const getElementsInGroup = ( - elements: readonly ExcalidrawElement[], + elements: ElementsMapOrArray, groupId: string, -) => elements.filter((element) => isElementInGroup(element, groupId)); +) => { + const elementsInGroup: ExcalidrawElement[] = []; + for (const element of elements.values()) { + if (isElementInGroup(element, groupId)) { + elementsInGroup.push(element); + } + } + return elementsInGroup; +}; export const getSelectedGroupIdForElement = ( element: ExcalidrawElement, @@ -320,12 +330,12 @@ export const removeFromSelectedGroups = ( export const getMaximumGroups = ( elements: ExcalidrawElement[], + elementsMap: ElementsMap, ): ExcalidrawElement[][] => { const groups: Map = new Map< String, ExcalidrawElement[] >(); - elements.forEach((element: ExcalidrawElement) => { const groupId = element.groupIds.length === 0 @@ -335,7 +345,7 @@ export const getMaximumGroups = ( const currentGroupMembers = groups.get(groupId) || []; // Include bound text if present when grouping - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { currentGroupMembers.push(boundTextElement); } diff --git a/packages/excalidraw/hooks/useCreatePortalContainer.ts b/packages/excalidraw/hooks/useCreatePortalContainer.ts index b13d95172..e8f5e3db6 100644 --- a/packages/excalidraw/hooks/useCreatePortalContainer.ts +++ b/packages/excalidraw/hooks/useCreatePortalContainer.ts @@ -1,5 +1,6 @@ import { useState, useLayoutEffect } from "react"; import { useDevice, useExcalidrawContainer } from "../components/App"; +import { THEME } from "../constants"; import { useUIAppState } from "../context/ui-appState"; export const useCreatePortalContainer = (opts?: { @@ -18,7 +19,7 @@ export const useCreatePortalContainer = (opts?: { div.className = ""; div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || [])); div.classList.toggle("excalidraw--mobile", device.editor.isMobile); - div.classList.toggle("theme--dark", theme === "dark"); + div.classList.toggle("theme--dark", theme === THEME.DARK); } }, [div, theme, device.editor.isMobile, opts?.className]); diff --git a/packages/excalidraw/hooks/useLibraryItemSvg.ts b/packages/excalidraw/hooks/useLibraryItemSvg.ts index 972b7f284..ac40140b4 100644 --- a/packages/excalidraw/hooks/useLibraryItemSvg.ts +++ b/packages/excalidraw/hooks/useLibraryItemSvg.ts @@ -2,7 +2,7 @@ import { atom, useAtom } from "jotai"; import { useEffect, useState } from "react"; import { COLOR_PALETTE } from "../colors"; import { jotaiScope } from "../jotai"; -import { exportToSvg } from "../../utils"; +import { exportToSvg } from "../../utils/export"; import { LibraryItem } from "../types"; export type SvgCache = Map; diff --git a/packages/excalidraw/hooks/useStableCallback.ts b/packages/excalidraw/hooks/useStableCallback.ts new file mode 100644 index 000000000..9920a73f6 --- /dev/null +++ b/packages/excalidraw/hooks/useStableCallback.ts @@ -0,0 +1,18 @@ +import { useRef } from "react"; + +/** + * Returns a stable function of the same type. + */ +export const useStableCallback = any>( + userFn: T, +) => { + const stableRef = useRef<{ userFn: T; stableFn?: T }>({ userFn }); + stableRef.current.userFn = userFn; + + if (!stableRef.current.stableFn) { + stableRef.current.stableFn = ((...args: any[]) => + stableRef.current.userFn(...args)) as T; + } + + return stableRef.current.stableFn as T; +}; diff --git a/packages/excalidraw/i18n.ts b/packages/excalidraw/i18n.ts index 6536b2c6d..a014b33b8 100644 --- a/packages/excalidraw/i18n.ts +++ b/packages/excalidraw/i18n.ts @@ -96,9 +96,7 @@ export const setLanguage = async (lang: Language) => { currentLangData = {}; } else { try { - currentLangData = await import( - /* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json` - ); + currentLangData = await import(`./locales/${currentLang.code}.json`); } catch (error: any) { console.error(`Failed to load language ${lang.code}:`, error.message); currentLangData = fallbackLangData; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index a4a82a1e0..e1dc29e66 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -5,6 +5,8 @@ import { isShallowEqual } from "./utils"; import "./css/app.scss"; import "./css/styles.scss"; +import "../../public/fonts/fonts.css"; +import polyfill from "./polyfill"; import { AppProps, ExcalidrawProps } from "./types"; import { defaultLang } from "./i18n"; @@ -16,6 +18,8 @@ import MainMenu from "./components/main-menu/MainMenu"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger"; +polyfill(); + const ExcalidrawBase = (props: ExcalidrawProps) => { const { onChange, @@ -40,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { generateIdForFile, onLinkOpen, onPointerDown, + onPointerUp, onScrollChange, children, validateEmbeddable, @@ -76,6 +81,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { } useEffect(() => { + const importPolyfill = async () => { + //@ts-ignore + await import("canvas-roundrect-polyfill"); + }; + + importPolyfill(); + // Block pinch-zooming on iOS outside of the content area const handleTouchMove = (event: TouchEvent) => { // @ts-ignore @@ -120,6 +132,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { generateIdForFile={generateIdForFile} onLinkOpen={onLinkOpen} onPointerDown={onPointerDown} + onPointerUp={onPointerUp} onScrollChange={onScrollChange} validateEmbeddable={validateEmbeddable} renderEmbeddable={renderEmbeddable} @@ -194,6 +207,8 @@ Excalidraw.displayName = "Excalidraw"; export { getSceneVersion, + hashElementsVersion, + hashString, isInvisiblySmallElement, getNonDeletedElements, } from "./element"; @@ -204,22 +219,31 @@ export { restoreElements, restoreLibraryItems, } from "./data/restore"; + export { exportToCanvas, exportToBlob, exportToSvg, - serializeAsJSON, - serializeLibraryAsJSON, - loadLibraryFromBlob, + exportToClipboard, +} from "../utils/export"; + +export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; +export { loadFromBlob, loadSceneOrLibraryFromBlob, - getFreeDrawSvgPath, - exportToClipboard, - mergeLibraryItems, -} from "../utils"; + loadLibraryFromBlob, +} from "./data/blob"; +export { getFreeDrawSvgPath } from "./renderer/renderElement"; +export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; -export { FONT_FAMILY, THEME, MIME_TYPES } from "./constants"; +export { + FONT_FAMILY, + THEME, + MIME_TYPES, + ROUNDNESS, + DEFAULT_LASER_COLOR, +} from "./constants"; export { mutateElement, @@ -247,11 +271,12 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; export { normalizeLink } from "./data/url"; +export { zoomToFitBounds } from "./actions/actionCanvas"; export { convertToExcalidrawElements } from "./data/transform"; -export { getCommonBounds } from "./element/bounds"; +export { getCommonBounds, getVisibleSceneBounds } from "./element/bounds"; export { elementsOverlappingBBox, isElementInsideBBox, elementPartiallyOverlapsWithOrContainsBBox, -} from "../withinBounds"; +} from "../utils/withinBounds"; diff --git a/packages/excalidraw/keys.ts b/packages/excalidraw/keys.ts index f7bf54db5..755ce3a84 100644 --- a/packages/excalidraw/keys.ts +++ b/packages/excalidraw/keys.ts @@ -45,6 +45,7 @@ export const KEYS = { PERIOD: ".", COMMA: ",", SUBTRACT: "-", + SLASH: "/", A: "a", C: "c", diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts new file mode 100644 index 000000000..e2ef258b0 --- /dev/null +++ b/packages/excalidraw/laser-trails.ts @@ -0,0 +1,127 @@ +import { LaserPointerOptions } from "@excalidraw/laser-pointer"; +import { AnimatedTrail, Trail } from "./animated-trail"; +import { AnimationFrameHandler } from "./animation-frame-handler"; +import type App from "./components/App"; +import { SocketId } from "./types"; +import { easeOut } from "./utils"; +import { getClientColor } from "./clients"; +import { DEFAULT_LASER_COLOR } from "./constants"; + +export class LaserTrails implements Trail { + public localTrail: AnimatedTrail; + private collabTrails = new Map(); + + private container?: SVGSVGElement; + + constructor( + private animationFrameHandler: AnimationFrameHandler, + private app: App, + ) { + this.animationFrameHandler.register(this, this.onFrame.bind(this)); + + this.localTrail = new AnimatedTrail(animationFrameHandler, app, { + ...this.getTrailOptions(), + fill: () => DEFAULT_LASER_COLOR, + }); + } + + private getTrailOptions() { + return { + simplify: 0, + streamline: 0.4, + sizeMapping: (c) => { + const DECAY_TIME = 1000; + const DECAY_LENGTH = 50; + const t = Math.max( + 0, + 1 - (performance.now() - c.pressure) / DECAY_TIME, + ); + const l = + (DECAY_LENGTH - + Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / + DECAY_LENGTH; + + return Math.min(easeOut(l), easeOut(t)); + }, + } as Partial; + } + + startPath(x: number, y: number): void { + this.localTrail.startPath(x, y); + } + + addPointToPath(x: number, y: number): void { + this.localTrail.addPointToPath(x, y); + } + + endPath(): void { + this.localTrail.endPath(); + } + + start(container: SVGSVGElement) { + this.container = container; + + this.animationFrameHandler.start(this); + this.localTrail.start(container); + } + + stop() { + this.animationFrameHandler.stop(this); + this.localTrail.stop(); + } + + onFrame() { + this.updateCollabTrails(); + } + + private updateCollabTrails() { + if (!this.container || this.app.state.collaborators.size === 0) { + return; + } + + for (const [key, collaborator] of this.app.state.collaborators.entries()) { + let trail!: AnimatedTrail; + + if (!this.collabTrails.has(key)) { + trail = new AnimatedTrail(this.animationFrameHandler, this.app, { + ...this.getTrailOptions(), + fill: () => + collaborator.pointer?.laserColor || + getClientColor(key, collaborator), + }); + trail.start(this.container); + + this.collabTrails.set(key, trail); + } else { + trail = this.collabTrails.get(key)!; + } + + if (collaborator.pointer && collaborator.pointer.tool === "laser") { + if (collaborator.button === "down" && !trail.hasCurrentTrail) { + trail.startPath(collaborator.pointer.x, collaborator.pointer.y); + } + + if ( + collaborator.button === "down" && + trail.hasCurrentTrail && + !trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y) + ) { + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); + } + + if (collaborator.button === "up" && trail.hasCurrentTrail) { + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); + trail.endPath(); + } + } + } + + for (const key of this.collabTrails.keys()) { + if (!this.app.state.collaborators.has(key)) { + const trail = this.collabTrails.get(key)!; + trail.stop(); + this.collabTrails.delete(key); + } + } + } +} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 2f845bb10..a0a5a1958 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -21,7 +21,9 @@ "copyStyles": "Copy styles", "pasteStyles": "Paste styles", "stroke": "Stroke", + "changeStroke": "Change stroke color", "background": "Background", + "changeBackground": "Change background color", "fill": "Fill", "strokeWidth": "Stroke width", "strokeStyle": "Stroke style", @@ -72,6 +74,7 @@ "canvasColors": "Used on canvas", "canvasBackground": "Canvas background", "drawingCanvas": "Drawing canvas", + "clearCanvas": "Clear canvas", "layers": "Layers", "actions": "Actions", "language": "Language", @@ -90,6 +93,7 @@ "libraryLoadingMessage": "Loading library…", "libraries": "Browse libraries", "loadingScene": "Loading scene…", + "loadScene": "Load scene from file", "align": "Align", "alignTop": "Align top", "alignBottom": "Align bottom", @@ -105,7 +109,8 @@ "share": "Share", "showStroke": "Show stroke color picker", "showBackground": "Show background color picker", - "toggleTheme": "Toggle theme", + "toggleTheme": "Toggle light/dark theme", + "theme": "Theme", "personalLib": "Personal Library", "excalidrawLib": "Excalidraw Library", "decreaseFontSize": "Decrease font size", @@ -138,7 +143,12 @@ "removeAllElementsFromFrame": "Remove all elements from frame", "eyeDropper": "Pick color from canvas", "textToDiagram": "Text to diagram", - "prompt": "Prompt" + "prompt": "Prompt", + "followUs": "Follow us", + "discordChat": "Discord chat", + "zoomToFitViewport": "Zoom to fit in viewport", + "zoomToFitSelection": "Zoom to fit selection", + "zoomToFit": "Zoom to fit all elements" }, "library": { "noItems": "No items added yet...", @@ -171,6 +181,7 @@ "fullScreen": "Full screen", "darkMode": "Dark mode", "lightMode": "Light mode", + "systemMode": "System mode", "zenMode": "Zen mode", "objectsSnapMode": "Snap to objects", "exitZenMode": "Exit zen mode", @@ -212,9 +223,9 @@ "fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.", "svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.", "failedToFetchImage": "Failed to fetch image.", - "invalidSVGString": "Invalid SVG.", "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "importLibraryError": "Couldn't load library", + "saveLibraryError": "Couldn't save library to storage. Please save your library to a file locally to make sure you don't lose changes.", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.", "imageToolNotSupported": "Images are disabled.", @@ -246,7 +257,7 @@ "library": "Library", "lock": "Keep selected tool active after drawing", "penMode": "Pen mode - prevent touch", - "link": "Add/ Update link for a selected shape", + "link": "Add / Update link for a selected shape", "eraser": "Eraser", "frame": "Frame tool", "magicframe": "Wireframe to code", @@ -299,9 +310,12 @@ "openIssueMessage": "We were very cautious not to include your scene information on the error. If your scene is not private, please consider following up on our . Please include information below by copying and pasting into the GitHub issue.", "sceneContent": "Scene content:" }, + "shareDialog": { + "or": "Or" + }, "roomDialog": { - "desc_intro": "You can invite people to your current scene to collaborate with you.", - "desc_privacy": "Don't worry, the session uses end-to-end encryption, so whatever you draw will stay private. Not even our server will be able to see what you come up with.", + "desc_intro": "Invite people to collaborate on your drawing.", + "desc_privacy": "Don't worry, the session is end-to-end encrypted, and fully private. Not even our server can see what you draw.", "button_startSession": "Start session", "button_stopSession": "Stop session", "desc_inProgressIntro": "Live-collaboration session is now in progress.", @@ -521,5 +535,33 @@ "description": "Currently only Flowchart, Sequence, and Class Diagrams are supported. The other types will be rendered as image in Excalidraw.", "syntax": "Mermaid Syntax", "preview": "Preview" + }, + "userList": { + "search": { + "placeholder": "Quick search", + "empty": "No users found" + }, + "hint": { + "text": "Click on user to follow", + "followStatus": "You're currently following this user", + "inCall": "User is in a voice call", + "micMuted": "User's microphone is muted", + "isSpeaking": "User is speaking" + } + }, + "commandPalette": { + "title": "Command palette", + "shortcuts": { + "select": "Select", + "confirm": "Confirm", + "close": "Close" + }, + "recents": "Recently used", + "search": { + "placeholder": "Search menus, commands, and discover hidden gems", + "noMatch": "No matching commands..." + }, + "itemNotAvailable": "Command is not available...", + "shortcutHint": "For Command palette, use {{shortcut}}" } } diff --git a/packages/excalidraw/main.js b/packages/excalidraw/main.js deleted file mode 100644 index 853bb70f8..000000000 --- a/packages/excalidraw/main.js +++ /dev/null @@ -1,11 +0,0 @@ -if (process.env.IS_PREACT === "true") { - if (process.env.NODE_ENV === "production") { - module.exports = require("./dist/excalidraw-with-preact.production.min.js"); - } else { - module.exports = require("./dist/excalidraw-with-preact.development.js"); - } -} else if (process.env.NODE_ENV === "production") { - module.exports = require("./dist/excalidraw.production.min.js"); -} else { - module.exports = require("./dist/excalidraw.development.js"); -} diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 476630347..d142bdd02 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -1,11 +1,23 @@ { "name": "@excalidraw/excalidraw", "version": "0.17.1", - "main": "main.js", - "types": "types/packages/excalidraw/index.d.ts", + "main": "./dist/prod/index.js", + "type": "module", + "module": "./dist/prod/index.js", + "exports": { + ".": { + "development": "./dist/dev/index.js", + "types": "./dist/excalidraw/index.d.ts", + "default": "./dist/prod/index.js" + }, + "./index.css": { + "development": "./dist/dev/index.css", + "default": "./dist/prod/index.css" + } + }, + "types": "./dist/excalidraw/index.d.ts", "files": [ - "dist/*", - "types/*" + "dist/*" ], "publishConfig": { "access": "public" @@ -45,20 +57,18 @@ }, "dependencies": { "@braintree/sanitize-url": "6.0.2", - "@excalidraw/laser-pointer": "1.2.0", - "@excalidraw/mermaid-to-excalidraw": "0.2.0", + "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/mermaid-to-excalidraw": "0.3.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", - "@testing-library/jest-dom": "5.16.2", - "@testing-library/react": "12.1.5", "@tldraw/vec": "1.7.1", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", - "eslint-plugin-react": "7.32.2", - "fake-indexeddb": "3.1.7", + "fractional-indexing": "3.2.0", + "fuzzy": "0.1.3", "image-blob-reduce": "3.0.1", "jotai": "1.13.1", "lodash.throttle": "4.1.1", @@ -77,9 +87,6 @@ "tunnel-rat": "0.1.2" }, "devDependencies": { - "@types/pako": "1.0.3", - "@types/pica": "5.1.3", - "@types/resize-observer-browser": "0.1.7", "@babel/core": "7.18.9", "@babel/plugin-transform-arrow-functions": "7.18.6", "@babel/plugin-transform-async-to-generator": "7.18.6", @@ -89,36 +96,41 @@ "@babel/preset-react": "7.18.6", "@babel/preset-typescript": "7.18.6", "@size-limit/preset-big-lib": "9.0.0", + "@testing-library/jest-dom": "5.16.2", + "@testing-library/react": "12.1.5", + "@types/pako": "1.0.3", + "@types/pica": "5.1.3", + "@types/resize-observer-browser": "0.1.7", "autoprefixer": "10.4.7", "babel-loader": "8.2.5", "babel-plugin-transform-class-properties": "6.24.1", "cross-env": "7.0.3", "css-loader": "6.7.1", "dotenv": "16.0.1", + "esbuild": "0.19.10", + "esbuild-plugin-external-global": "1.0.1", + "esbuild-sass-plugin": "2.16.0", + "eslint-plugin-react": "7.32.2", + "fake-indexeddb": "3.1.7", "import-meta-loader": "1.1.0", "mini-css-extract-plugin": "2.6.1", "postcss-loader": "7.0.1", + "react": "18.2.0", + "react-dom": "18.2.0", "sass-loader": "13.0.2", "size-limit": "9.0.0", "style-loader": "3.3.3", - "terser-webpack-plugin": "5.3.3", "ts-loader": "9.3.1", - "typescript": "4.9.4", - "webpack": "5.76.0", - "webpack-bundle-analyzer": "4.5.0", - "webpack-cli": "4.10.0", - "webpack-dev-server": "4.9.3", - "webpack-merge": "5.8.0" + "typescript": "4.9.4" }, "bugs": "https://github.com/excalidraw/excalidraw/issues", "homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw", "scripts": { - "gen:types": "tsc --project tsconfig-types.json", - "build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && NODE_ENV=development webpack --config webpack.preact.config.js && NODE_ENV=production webpack --config webpack.preact.config.js && yarn gen:types", - "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", + "gen:types": "rm -rf types && tsc", + "build:esm": "rm -rf dist && node ../../scripts/buildPackage.js && yarn gen:types", "pack": "yarn build:umd && yarn pack", - "start": "webpack serve --config webpack.dev-server.config.js", - "build:example": "EXAMPLE=true webpack --config webpack.dev-server.config.js && yarn gen:types", + "start": "node ../../scripts/buildExample.mjs && vite", + "build:example": "node ../../scripts/buildExample.mjs", "size": "yarn build:umd && size-limit" } } diff --git a/packages/excalidraw/publicPath.js b/packages/excalidraw/publicPath.js deleted file mode 100644 index 3eb6bd272..000000000 --- a/packages/excalidraw/publicPath.js +++ /dev/null @@ -1,8 +0,0 @@ -import { ENV } from "./constants"; -if (process.env.NODE_ENV !== ENV.TEST) { - /* eslint-disable */ - /* global __webpack_public_path__:writable */ - __webpack_public_path__ = - window.EXCALIDRAW_ASSET_PATH || - `https://unpkg.com/${process.env.VITE_PKG_NAME}@${process.env.VITE_PKG_VERSION}/dist/`; -} diff --git a/packages/excalidraw/queue.test.ts b/packages/excalidraw/queue.test.ts new file mode 100644 index 000000000..66a10583e --- /dev/null +++ b/packages/excalidraw/queue.test.ts @@ -0,0 +1,62 @@ +import { Queue } from "./queue"; + +describe("Queue", () => { + const calls: any[] = []; + + const createJobFactory = + ( + // for purpose of this test, Error object will become a rejection value + resolutionOrRejectionValue: T, + ms = 1, + ) => + () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (resolutionOrRejectionValue instanceof Error) { + reject(resolutionOrRejectionValue); + } else { + resolve(resolutionOrRejectionValue); + } + }, ms); + }).then((x) => { + calls.push(x); + return x; + }); + }; + + beforeEach(() => { + calls.length = 0; + }); + + it("should await and resolve values in order of enqueueing", async () => { + const queue = new Queue(); + + const p1 = queue.push(createJobFactory("A", 50)); + const p2 = queue.push(createJobFactory("B")); + const p3 = queue.push(createJobFactory("C")); + + expect(await p3).toBe("C"); + expect(await p2).toBe("B"); + expect(await p1).toBe("A"); + + expect(calls).toEqual(["A", "B", "C"]); + }); + + it("should reject a job if it throws, and not affect other jobs", async () => { + const queue = new Queue(); + + const err = new Error("B"); + + queue.push(createJobFactory("A", 50)); + const p2 = queue.push(createJobFactory(err)); + const p3 = queue.push(createJobFactory("C")); + + const p2err = p2.catch((err) => err); + + await p3; + + expect(await p2err).toBe(err); + + expect(calls).toEqual(["A", "C"]); + }); +}); diff --git a/packages/excalidraw/queue.ts b/packages/excalidraw/queue.ts new file mode 100644 index 000000000..408e945ba --- /dev/null +++ b/packages/excalidraw/queue.ts @@ -0,0 +1,45 @@ +import { MaybePromise } from "./utility-types"; +import { promiseTry, ResolvablePromise, resolvablePromise } from "./utils"; + +type Job = (...args: TArgs) => MaybePromise; + +type QueueJob = { + jobFactory: Job; + promise: ResolvablePromise; + args: TArgs; +}; + +export class Queue { + private jobs: QueueJob[] = []; + private running = false; + + private tick() { + if (this.running) { + return; + } + const job = this.jobs.shift(); + if (job) { + this.running = true; + job.promise.resolve( + promiseTry(job.jobFactory, ...job.args).finally(() => { + this.running = false; + this.tick(); + }), + ); + } else { + this.running = false; + } + } + + push( + jobFactory: Job, + ...args: TArgs + ): Promise { + const promise = resolvablePromise(); + this.jobs.push({ jobFactory, promise, args }); + + this.tick(); + + return promise; + } +} diff --git a/packages/excalidraw/reactUtils.ts b/packages/excalidraw/reactUtils.ts new file mode 100644 index 000000000..535302d42 --- /dev/null +++ b/packages/excalidraw/reactUtils.ts @@ -0,0 +1,61 @@ +/** + * @param func handler taking at most single parameter (event). + */ + +import { unstable_batchedUpdates } from "react-dom"; +import { version as ReactVersion } from "react"; +import { throttleRAF } from "./utils"; + +export const withBatchedUpdates = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => + ((event) => { + unstable_batchedUpdates(func as TFunction, event); + }) as TFunction; + +/** + * barches React state updates and throttles the calls to a single call per + * animation frame + */ +export const withBatchedUpdatesThrottled = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => { + // @ts-ignore + return throttleRAF>(((event) => { + unstable_batchedUpdates(func, event); + }) as TFunction); +}; + +export const isRenderThrottlingEnabled = (() => { + // we don't want to throttle in react < 18 because of #5439 and it was + // getting more complex to maintain the fix + let IS_REACT_18_AND_UP: boolean; + try { + const version = ReactVersion.split("."); + IS_REACT_18_AND_UP = Number(version[0]) > 17; + } catch { + IS_REACT_18_AND_UP = false; + } + + let hasWarned = false; + + return () => { + if (window.EXCALIDRAW_THROTTLE_RENDER === true) { + if (!IS_REACT_18_AND_UP) { + if (!hasWarned) { + hasWarned = true; + console.warn( + "Excalidraw: render throttling is disabled on React versions < 18.", + ); + } + return false; + } + return true; + } + return false; + }; +})(); diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts new file mode 100644 index 000000000..8ac743324 --- /dev/null +++ b/packages/excalidraw/renderer/helpers.ts @@ -0,0 +1,75 @@ +import { StaticCanvasAppState, AppState } from "../types"; + +import { StaticCanvasRenderConfig } from "../scene/types"; + +import { THEME, THEME_FILTER } from "../constants"; + +export const fillCircle = ( + context: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + stroke = true, +) => { + context.beginPath(); + context.arc(cx, cy, radius, 0, Math.PI * 2); + context.fill(); + if (stroke) { + context.stroke(); + } +}; + +export const getNormalizedCanvasDimensions = ( + canvas: HTMLCanvasElement, + scale: number, +): [number, number] => { + // When doing calculations based on canvas width we should used normalized one + return [canvas.width / scale, canvas.height / scale]; +}; + +export const bootstrapCanvas = ({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme, + isExporting, + viewBackgroundColor, +}: { + canvas: HTMLCanvasElement; + scale: number; + normalizedWidth: number; + normalizedHeight: number; + theme?: AppState["theme"]; + isExporting?: StaticCanvasRenderConfig["isExporting"]; + viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; +}): CanvasRenderingContext2D => { + const context = canvas.getContext("2d")!; + + context.setTransform(1, 0, 0, 1, 0, 0); + context.scale(scale, scale); + + if (isExporting && theme === THEME.DARK) { + context.filter = THEME_FILTER; + } + + // Paint background + if (typeof viewBackgroundColor === "string") { + const hasTransparence = + viewBackgroundColor === "transparent" || + viewBackgroundColor.length === 5 || // #RGBA + viewBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(viewBackgroundColor); + if (hasTransparence) { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + context.save(); + context.fillStyle = viewBackgroundColor; + context.fillRect(0, 0, normalizedWidth, normalizedHeight); + context.restore(); + } else { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + + return context; +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/interactiveScene.ts similarity index 52% rename from packages/excalidraw/renderer/renderScene.ts rename to packages/excalidraw/renderer/interactiveScene.ts index c41d59bd3..b1855cfe8 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,23 +1,3 @@ -import { RoughSVG } from "roughjs/bin/svg"; -import oc from "open-color"; - -import { - InteractiveCanvasAppState, - StaticCanvasAppState, - BinaryFiles, - Point, - Zoom, - AppState, -} from "../types"; -import { - ExcalidrawElement, - NonDeletedExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, - GroupId, - ExcalidrawBindableElement, - ExcalidrawFrameLikeElement, -} from "../element/types"; import { getElementAbsoluteCoords, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -26,67 +6,107 @@ import { getCommonBounds, } from "../element"; -import { roundRect } from "./roundRect"; -import { - InteractiveCanvasRenderConfig, - InteractiveSceneRenderConfig, - SVGRenderConfig, - StaticCanvasRenderConfig, - StaticSceneRenderConfig, -} from "../scene/types"; +import { roundRect } from "../renderer/roundRect"; + import { getScrollBars, SCROLLBAR_COLOR, SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { - renderElement, - renderElementToSvg, - renderSelectionElement, -} from "./renderElement"; -import { getClientColor } from "../clients"; -import { LinearElementEditor } from "../element/linearElementEditor"; +import { renderSelectionElement } from "../renderer/renderElement"; +import { getClientColor, renderRemoteCursors } from "../clients"; import { isSelectedViaGroup, getSelectedGroupIds, getElementsInGroup, selectGroupsFromGivenElements, } from "../groups"; -import { maxBindingGap } from "../element/collision"; -import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; import { OMIT_SIDES_FOR_FRAME, shouldShowBoundingBox, TransformHandles, TransformHandleType, } from "../element/transformHandles"; -import { throttleRAF } from "../utils"; -import { UserIdleState } from "../types"; -import { FRAME_STYLE, THEME_FILTER } from "../constants"; -import { - EXTERNAL_LINK_IMG, - getLinkHandleFromCoords, -} from "../element/Hyperlink"; -import { renderSnaps } from "./renderSnaps"; -import { - isEmbeddableElement, - isFrameLikeElement, - isIframeLikeElement, - isLinearElement, -} from "../element/typeChecks"; -import { - isIframeLikeOrItsLabel, - createPlaceholderEmbeddableLabel, -} from "../element/embeddable"; -import { - elementOverlapsWithFrame, - getTargetFrame, - isElementInFrame, -} from "../frame"; -import "canvas-roundrect-polyfill"; +import { arrayToMap, throttleRAF } from "../utils"; +import { InteractiveCanvasAppState, Point } from "../types"; +import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; -export const DEFAULT_SPACING = 2; +import { renderSnaps } from "../renderer/renderSnaps"; + +import { + maxBindingGap, + SuggestedBinding, + SuggestedPointBinding, +} from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + bootstrapCanvas, + fillCircle, + getNormalizedCanvasDimensions, +} from "./helpers"; +import oc from "open-color"; +import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; +import { + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + ExcalidrawLinearElement, + GroupId, + NonDeleted, +} from "../element/types"; +import { + InteractiveCanvasRenderConfig, + InteractiveSceneRenderConfig, + RenderableElementsMap, +} from "../scene/types"; + +const renderLinearElementPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, +) => { + const { elementId, hoverPointIndex } = appState.selectedLinearElement!; + if ( + appState.editingLinearElement?.selectedPointsIndices?.includes( + hoverPointIndex, + ) + ) { + return; + } + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return; + } + const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + hoverPointIndex, + elementsMap, + ); + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(point, context, appState); + context.restore(); +}; + +const highlightPoint = ( + point: Point, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + context.fillStyle = "rgba(105, 101, 219, 0.4)"; + + fillCircle( + context, + point[0], + point[1], + LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, + false, + ); +}; const strokeRectWithRotation = ( context: CanvasRenderingContext2D, @@ -139,86 +159,6 @@ const strokeDiamondWithRotation = ( context.restore(); }; -const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - -const fillCircle = ( - context: CanvasRenderingContext2D, - cx: number, - cy: number, - radius: number, - stroke = true, -) => { - context.beginPath(); - context.arc(cx, cy, radius, 0, Math.PI * 2); - context.fill(); - if (stroke) { - context.stroke(); - } -}; - -const strokeGrid = ( - context: CanvasRenderingContext2D, - gridSize: number, - scrollX: number, - scrollY: number, - zoom: Zoom, - width: number, - height: number, -) => { - const BOLD_LINE_FREQUENCY = 5; - - enum GridLineColor { - Bold = "#cccccc", - Regular = "#e5e5e5", - } - - const offsetX = - -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); - const offsetY = - -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); - - const lineWidth = Math.min(1 / zoom.value, 1); - - const spaceWidth = 1 / zoom.value; - const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; - - context.save(); - context.lineWidth = lineWidth; - - for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { - const isBold = - Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(x, offsetY - gridSize); - context.lineTo(x, offsetY + height + gridSize * 2); - context.stroke(); - } - for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { - const isBold = - Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(offsetX - gridSize, y); - context.lineTo(offsetX + width + gridSize * 2, y); - context.stroke(); - } - context.restore(); -}; - const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -245,992 +185,25 @@ const renderSingleLinearPoint = ( ); }; -const renderLinearPointHandles = ( +const strokeEllipseWithRotation = ( context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - element: NonDeleted, -) => { - if (!appState.selectedLinearElement) { - return; - } - context.save(); - context.translate(appState.scrollX, appState.scrollY); - context.lineWidth = 1 / appState.zoom.value; - const points = LinearElementEditor.getPointsGlobalCoordinates(element); - - const { POINT_HANDLE_SIZE } = LinearElementEditor; - const radius = appState.editingLinearElement - ? POINT_HANDLE_SIZE - : POINT_HANDLE_SIZE / 2; - points.forEach((point, idx) => { - const isSelected = - !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); - - renderSingleLinearPoint(context, appState, point, radius, isSelected); - }); - - //Rendering segment mid points - const midPoints = LinearElementEditor.getEditorMidPoints( - element, - appState, - ).filter((midPoint) => midPoint !== null) as Point[]; - - midPoints.forEach((segmentMidPoint) => { - if ( - appState?.selectedLinearElement?.segmentMidPointHoveredCoords && - LinearElementEditor.arePointsEqual( - segmentMidPoint, - appState.selectedLinearElement.segmentMidPointHoveredCoords, - ) - ) { - // The order of renderingSingleLinearPoint and highLight points is different - // inside vs outside editor as hover states are different, - // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the - // editor original point is visible and hover state is just an outer circle. - if (appState.editingLinearElement) { - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - radius, - false, - ); - highlightPoint(segmentMidPoint, context, appState); - } else { - highlightPoint(segmentMidPoint, context, appState); - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - radius, - false, - ); - } - } else if (appState.editingLinearElement || points.length === 2) { - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - POINT_HANDLE_SIZE / 2, - false, - true, - ); - } - }); - - context.restore(); -}; - -const highlightPoint = ( - point: Point, - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, -) => { - context.fillStyle = "rgba(105, 101, 219, 0.4)"; - - fillCircle( - context, - point[0], - point[1], - LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, - false, - ); -}; -const renderLinearElementPointHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, -) => { - const { elementId, hoverPointIndex } = appState.selectedLinearElement!; - if ( - appState.editingLinearElement?.selectedPointsIndices?.includes( - hoverPointIndex, - ) - ) { - return; - } - const element = LinearElementEditor.getElement(elementId); - if (!element) { - return; - } - const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - hoverPointIndex, - ); - context.save(); - context.translate(appState.scrollX, appState.scrollY); - - highlightPoint(point, context, appState); - context.restore(); -}; - -const frameClip = ( - frame: ExcalidrawFrameLikeElement, - context: CanvasRenderingContext2D, - renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, -) => { - context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); - context.beginPath(); - if (context.roundRect) { - context.roundRect( - 0, - 0, - frame.width, - frame.height, - FRAME_STYLE.radius / appState.zoom.value, - ); - } else { - context.rect(0, 0, frame.width, frame.height); - } - context.clip(); - context.translate( - -(frame.x + appState.scrollX), - -(frame.y + appState.scrollY), - ); -}; - -const getNormalizedCanvasDimensions = ( - canvas: HTMLCanvasElement, - scale: number, -): [number, number] => { - // When doing calculations based on canvas width we should used normalized one - return [canvas.width / scale, canvas.height / scale]; -}; - -const bootstrapCanvas = ({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme, - isExporting, - viewBackgroundColor, -}: { - canvas: HTMLCanvasElement; - scale: number; - normalizedWidth: number; - normalizedHeight: number; - theme?: AppState["theme"]; - isExporting?: StaticCanvasRenderConfig["isExporting"]; - viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; -}): CanvasRenderingContext2D => { - const context = canvas.getContext("2d")!; - - context.setTransform(1, 0, 0, 1, 0, 0); - context.scale(scale, scale); - - if (isExporting && theme === "dark") { - context.filter = THEME_FILTER; - } - - // Paint background - if (typeof viewBackgroundColor === "string") { - const hasTransparence = - viewBackgroundColor === "transparent" || - viewBackgroundColor.length === 5 || // #RGBA - viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(viewBackgroundColor); - if (hasTransparence) { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); - } - context.save(); - context.fillStyle = viewBackgroundColor; - context.fillRect(0, 0, normalizedWidth, normalizedHeight); - context.restore(); - } else { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); - } - - return context; -}; - -const _renderInteractiveScene = ({ - canvas, - elements, - visibleElements, - selectedElements, - scale, - appState, - renderConfig, -}: InteractiveSceneRenderConfig) => { - if (canvas === null) { - return { atLeastOneVisibleElement: false, elements }; - } - - const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( - canvas, - scale, - ); - - const context = bootstrapCanvas({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - }); - - // Apply zoom - context.save(); - context.scale(appState.zoom.value, appState.zoom.value); - - let editingLinearElement: NonDeleted | undefined = - undefined; - - visibleElements.forEach((element) => { - // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to - // ShapeCache returns empty hence making sure that we get the - // correct element from visible elements - if (appState.editingLinearElement?.elementId === element.id) { - if (element) { - editingLinearElement = element as NonDeleted; - } - } - }); - - if (editingLinearElement) { - renderLinearPointHandles(context, appState, editingLinearElement); - } - - // Paint selection element - if (appState.selectionElement) { - try { - renderSelectionElement(appState.selectionElement, context, appState); - } catch (error: any) { - console.error(error); - } - } - - if (appState.isBindingEnabled) { - appState.suggestedBindings - .filter((binding) => binding != null) - .forEach((suggestedBinding) => { - renderBindingHighlight(context, appState, suggestedBinding!); - }); - } - - if (appState.frameToHighlight) { - renderFrameHighlight(context, appState, appState.frameToHighlight); - } - - if (appState.elementsToHighlight) { - renderElementsBoxHighlight(context, appState, appState.elementsToHighlight); - } - - const isFrameSelected = selectedElements.some((element) => - isFrameLikeElement(element), - ); - - // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to - // ShapeCache returns empty hence making sure that we get the - // correct element from visible elements - if ( - selectedElements.length === 1 && - appState.editingLinearElement?.elementId === selectedElements[0].id - ) { - renderLinearPointHandles( - context, - appState, - selectedElements[0] as NonDeleted, - ); - } - - if ( - appState.selectedLinearElement && - appState.selectedLinearElement.hoverPointIndex >= 0 - ) { - renderLinearElementPointHighlight(context, appState); - } - // Paint selected elements - if (!appState.multiElement && !appState.editingLinearElement) { - const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); - - const isSingleLinearElementSelected = - selectedElements.length === 1 && isLinearElement(selectedElements[0]); - // render selected linear element points - if ( - isSingleLinearElementSelected && - appState.selectedLinearElement?.elementId === selectedElements[0].id && - !selectedElements[0].locked - ) { - renderLinearPointHandles( - context, - appState, - selectedElements[0] as ExcalidrawLinearElement, - ); - } - const selectionColor = renderConfig.selectionColor || oc.black; - - if (showBoundingBox) { - // Optimisation for finding quickly relevant element ids - const locallySelectedIds = selectedElements.reduce( - (acc: Record, element) => { - acc[element.id] = true; - return acc; - }, - {}, - ); - - const selections = elements.reduce( - ( - acc: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }[], - element, - ) => { - const selectionColors = []; - // local user - if ( - locallySelectedIds[element.id] && - !isSelectedViaGroup(appState, element) - ) { - selectionColors.push(selectionColor); - } - // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { - selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId: string) => { - const background = getClientColor(socketId); - return background; - }, - ), - ); - } - - if (selectionColors.length) { - const [elementX1, elementY1, elementX2, elementY2, cx, cy] = - getElementAbsoluteCoords(element, true); - acc.push({ - angle: element.angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - dashed: !!renderConfig.remoteSelectedElementIds[element.id], - cx, - cy, - activeEmbeddable: - appState.activeEmbeddable?.element === element && - appState.activeEmbeddable.state === "active", - }); - } - return acc; - }, - [], - ); - - const addSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(groupElements); - selections.push({ - angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, - selectionColors: [oc.black], - dashed: true, - cx: elementX1 + (elementX2 - elementX1) / 2, - cy: elementY1 + (elementY2 - elementY1) / 2, - activeEmbeddable: false, - }); - }; - - for (const groupId of getSelectedGroupIds(appState)) { - // TODO: support multiplayer selected group IDs - addSelectionForGroupId(groupId); - } - - if (appState.editingGroupId) { - addSelectionForGroupId(appState.editingGroupId); - } - - selections.forEach((selection) => - renderSelectionBorder(context, appState, selection), - ); - } - // Paint resize transformHandles - context.save(); - context.translate(appState.scrollX, appState.scrollY); - - if (selectedElements.length === 1) { - context.fillStyle = oc.white; - const transformHandles = getTransformHandles( - selectedElements[0], - appState.zoom, - "mouse", // when we render we don't know which pointer type so use mouse - ); - if (!appState.viewModeEnabled && showBoundingBox) { - renderTransformHandles( - context, - renderConfig, - appState, - transformHandles, - selectedElements[0].angle, - ); - } - } else if (selectedElements.length > 1 && !appState.isRotating) { - const dashedLinePadding = (DEFAULT_SPACING * 2) / appState.zoom.value; - context.fillStyle = oc.white; - const [x1, y1, x2, y2] = getCommonBounds(selectedElements); - const initialLineDash = context.getLineDash(); - context.setLineDash([2 / appState.zoom.value]); - const lineWidth = context.lineWidth; - context.lineWidth = 1 / appState.zoom.value; - context.strokeStyle = selectionColor; - strokeRectWithRotation( - context, - x1 - dashedLinePadding, - y1 - dashedLinePadding, - x2 - x1 + dashedLinePadding * 2, - y2 - y1 + dashedLinePadding * 2, - (x1 + x2) / 2, - (y1 + y2) / 2, - 0, - ); - context.lineWidth = lineWidth; - context.setLineDash(initialLineDash); - const transformHandles = getTransformHandlesFromCoords( - [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, - appState.zoom, - "mouse", - isFrameSelected - ? OMIT_SIDES_FOR_FRAME - : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, - ); - if (selectedElements.some((element) => !element.locked)) { - renderTransformHandles( - context, - renderConfig, - appState, - transformHandles, - 0, - ); - } - } - context.restore(); - } - - renderSnaps(context, appState); - - // Reset zoom - context.restore(); - - // Paint remote pointers - for (const clientId in renderConfig.remotePointerViewportCoords) { - let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; - - x -= appState.offsetLeft; - y -= appState.offsetTop; - - const width = 11; - const height = 14; - - const isOutOfBounds = - x < 0 || - x > normalizedWidth - width || - y < 0 || - y > normalizedHeight - height; - - x = Math.max(x, 0); - x = Math.min(x, normalizedWidth - width); - y = Math.max(y, 0); - y = Math.min(y, normalizedHeight - height); - - const background = getClientColor(clientId); - - context.save(); - context.strokeStyle = background; - context.fillStyle = background; - - const userState = renderConfig.remotePointerUserStates[clientId]; - const isInactive = - isOutOfBounds || - userState === UserIdleState.IDLE || - userState === UserIdleState.AWAY; - - if (isInactive) { - context.globalAlpha = 0.3; - } - - if ( - renderConfig.remotePointerButton && - renderConfig.remotePointerButton[clientId] === "down" - ) { - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 3; - context.strokeStyle = "#ffffff88"; - context.stroke(); - context.closePath(); - - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 1; - context.strokeStyle = background; - context.stroke(); - context.closePath(); - } - - // Background (white outline) for arrow - context.fillStyle = oc.white; - context.strokeStyle = oc.white; - context.lineWidth = 6; - context.lineJoin = "round"; - context.beginPath(); - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.stroke(); - context.fill(); - - // Arrow - context.fillStyle = background; - context.strokeStyle = background; - context.lineWidth = 2; - context.lineJoin = "round"; - context.beginPath(); - if (isInactive) { - context.moveTo(x - 1, y - 1); - context.lineTo(x - 1, y + 15); - context.lineTo(x + 5, y + 10); - context.lineTo(x + 12, y + 9); - context.closePath(); - context.fill(); - } else { - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.fill(); - context.stroke(); - } - - const username = renderConfig.remotePointerUsernames[clientId] || ""; - - if (!isOutOfBounds && username) { - context.font = "600 12px sans-serif"; // font has to be set before context.measureText() - - const offsetX = x + width / 2; - const offsetY = y + height + 2; - const paddingHorizontal = 5; - const paddingVertical = 3; - const measure = context.measureText(username); - const measureHeight = - measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; - const finalHeight = Math.max(measureHeight, 12); - - const boxX = offsetX - 1; - const boxY = offsetY - 1; - const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; - const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; - if (context.roundRect) { - context.beginPath(); - context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); - context.fillStyle = background; - context.fill(); - context.strokeStyle = oc.white; - context.stroke(); - } else { - roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white); - } - context.fillStyle = oc.black; - - context.fillText( - username, - offsetX + paddingHorizontal + 1, - offsetY + - paddingVertical + - measure.actualBoundingBoxAscent + - Math.floor((finalHeight - measureHeight) / 2) + - 2, - ); - } - - context.restore(); - context.closePath(); - } - - // Paint scrollbars - let scrollBars; - if (renderConfig.renderScrollbars) { - scrollBars = getScrollBars( - elements, - normalizedWidth, - normalizedHeight, - appState, - ); - - context.save(); - context.fillStyle = SCROLLBAR_COLOR; - context.strokeStyle = "rgba(255,255,255,0.8)"; - [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => { - if (scrollBar) { - roundRect( - context, - scrollBar.x, - scrollBar.y, - scrollBar.width, - scrollBar.height, - SCROLLBAR_WIDTH / 2, - ); - } - }); - context.restore(); - } - - return { - scrollBars, - atLeastOneVisibleElement: visibleElements.length > 0, - elements, - }; -}; - -const _renderStaticScene = ({ - canvas, - rc, - elements, - visibleElements, - scale, - appState, - renderConfig, -}: StaticSceneRenderConfig) => { - if (canvas === null) { - return; - } - - const { renderGrid = true, isExporting } = renderConfig; - - const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( - canvas, - scale, - ); - - const context = bootstrapCanvas({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme: appState.theme, - isExporting, - viewBackgroundColor: appState.viewBackgroundColor, - }); - - // Apply zoom - context.scale(appState.zoom.value, appState.zoom.value); - - // Grid - if (renderGrid && appState.gridSize) { - strokeGrid( - context, - appState.gridSize, - appState.scrollX, - appState.scrollY, - appState.zoom, - normalizedWidth / appState.zoom.value, - normalizedHeight / appState.zoom.value, - ); - } - - const groupsToBeAddedToFrame = new Set(); - - visibleElements.forEach((element) => { - if ( - element.groupIds.length > 0 && - appState.frameToHighlight && - appState.selectedElementIds[element.id] && - (elementOverlapsWithFrame(element, appState.frameToHighlight) || - element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) - ) { - element.groupIds.forEach((groupId) => - groupsToBeAddedToFrame.add(groupId), - ); - } - }); - - // Paint visible elements - visibleElements - .filter((el) => !isIframeLikeOrItsLabel(el)) - .forEach((element) => { - try { - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, appState); - - // TODO do we need to check isElementInFrame here? - if (frame && isElementInFrame(element, elements, appState)) { - frameClip(frame, context, renderConfig, appState); - } - renderElement(element, rc, context, renderConfig, appState); - context.restore(); - } else { - renderElement(element, rc, context, renderConfig, appState); - } - if (!isExporting) { - renderLinkIcon(element, context, appState); - } - } catch (error: any) { - console.error(error); - } - }); - - // render embeddables on top - visibleElements - .filter((el) => isIframeLikeOrItsLabel(el)) - .forEach((element) => { - try { - const render = () => { - renderElement(element, rc, context, renderConfig, appState); - - if ( - isIframeLikeElement(element) && - (isExporting || - (isEmbeddableElement(element) && !element.validated)) && - element.width && - element.height - ) { - const label = createPlaceholderEmbeddableLabel(element); - renderElement(label, rc, context, renderConfig, appState); - } - if (!isExporting) { - renderLinkIcon(element, context, appState); - } - }; - // - when exporting the whole canvas, we DO NOT apply clipping - // - when we are exporting a particular frame, apply clipping - // if the containing frame is not selected, apply clipping - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, appState); - - if (frame && isElementInFrame(element, elements, appState)) { - frameClip(frame, context, renderConfig, appState); - } - render(); - context.restore(); - } else { - render(); - } - } catch (error: any) { - console.error(error); - } - }); -}; - -/** throttled to animation framerate */ -const renderInteractiveSceneThrottled = throttleRAF( - (config: InteractiveSceneRenderConfig) => { - const ret = _renderInteractiveScene(config); - config.callback?.(ret); - }, - { trailing: true }, -); - -/** - * Interactive scene is the ui-canvas where we render boundinb boxes, selections - * and other ui stuff. - */ -export const renderInteractiveScene = < - U extends typeof _renderInteractiveScene, - T extends boolean = false, ->( - renderConfig: InteractiveSceneRenderConfig, - throttle?: T, -): T extends true ? void : ReturnType => { - if (throttle) { - renderInteractiveSceneThrottled(renderConfig); - return undefined as T extends true ? void : ReturnType; - } - const ret = _renderInteractiveScene(renderConfig); - renderConfig.callback(ret); - return ret as T extends true ? void : ReturnType; -}; - -/** throttled to animation framerate */ -const renderStaticSceneThrottled = throttleRAF( - (config: StaticSceneRenderConfig) => { - _renderStaticScene(config); - }, - { trailing: true }, -); - -/** - * Static scene is the non-ui canvas where we render elements. - */ -export const renderStaticScene = ( - renderConfig: StaticSceneRenderConfig, - throttle?: boolean, -) => { - if (throttle) { - renderStaticSceneThrottled(renderConfig); - return; - } - - _renderStaticScene(renderConfig); -}; - -export const cancelRender = () => { - renderInteractiveSceneThrottled.cancel(); - renderStaticSceneThrottled.cancel(); -}; - -const renderTransformHandles = ( - context: CanvasRenderingContext2D, - renderConfig: InteractiveCanvasRenderConfig, - appState: InteractiveCanvasAppState, - transformHandles: TransformHandles, + width: number, + height: number, + cx: number, + cy: number, angle: number, -): void => { - Object.keys(transformHandles).forEach((key) => { - const transformHandle = transformHandles[key as TransformHandleType]; - if (transformHandle !== undefined) { - const [x, y, width, height] = transformHandle; - - context.save(); - context.lineWidth = 1 / appState.zoom.value; - if (renderConfig.selectionColor) { - context.strokeStyle = renderConfig.selectionColor; - } - if (key === "rotation") { - fillCircle(context, x + width / 2, y + height / 2, width / 2); - // prefer round corners if roundRect API is available - } else if (context.roundRect) { - context.beginPath(); - context.roundRect(x, y, width, height, 2 / appState.zoom.value); - context.fill(); - context.stroke(); - } else { - strokeRectWithRotation( - context, - x, - y, - width, - height, - x + width / 2, - y + height / 2, - angle, - true, // fill before stroke - ); - } - context.restore(); - } - }); -}; - -const renderSelectionBorder = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementProperties: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }, - padding = DEFAULT_SPACING * 2, ) => { - const { - angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - cx, - cy, - dashed, - activeEmbeddable, - } = elementProperties; - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; - - const linePadding = padding / appState.zoom.value; - const lineWidth = 8 / appState.zoom.value; - const spaceWidth = 4 / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; - - const count = selectionColors.length; - for (let index = 0; index < count; ++index) { - context.strokeStyle = selectionColors[index]; - if (dashed) { - context.setLineDash([ - lineWidth, - spaceWidth + (lineWidth + spaceWidth) * (count - 1), - ]); - } - context.lineDashOffset = (lineWidth + spaceWidth) * index; - strokeRectWithRotation( - context, - elementX1 - linePadding, - elementY1 - linePadding, - elementWidth + linePadding * 2, - elementHeight + linePadding * 2, - cx, - cy, - angle, - ); - } - context.restore(); -}; - -const renderBindingHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - suggestedBinding: SuggestedBinding, -) => { - const renderHighlight = Array.isArray(suggestedBinding) - ? renderBindingHighlightForSuggestedPointBinding - : renderBindingHighlightForBindableElement; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any); - - context.restore(); + context.beginPath(); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); }; const renderBindingHighlightForBindableElement = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const width = x2 - x1; const height = y2 - y1; const threshold = maxBindingGap(element, width, height); @@ -1286,12 +259,122 @@ const renderBindingHighlightForBindableElement = ( } }; +const renderBindingHighlightForSuggestedPointBinding = ( + context: CanvasRenderingContext2D, + suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, +) => { + const [element, startOrEnd, bindableElement] = suggestedBinding; + + const threshold = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + ); + + context.strokeStyle = "rgba(0,0,0,0)"; + context.fillStyle = "rgba(0,0,0,.05)"; + + const pointIndices = + startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; + pointIndices.forEach((index) => { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + index, + elementsMap, + ); + fillCircle(context, x, y, threshold); + }); +}; + +const renderSelectionBorder = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementProperties: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }, + padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, +) => { + const { + angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + cx, + cy, + dashed, + activeEmbeddable, + } = elementProperties; + const elementWidth = elementX2 - elementX1; + const elementHeight = elementY2 - elementY1; + + const linePadding = padding / appState.zoom.value; + const lineWidth = 8 / appState.zoom.value; + const spaceWidth = 4 / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; + + const count = selectionColors.length; + for (let index = 0; index < count; ++index) { + context.strokeStyle = selectionColors[index]; + if (dashed) { + context.setLineDash([ + lineWidth, + spaceWidth + (lineWidth + spaceWidth) * (count - 1), + ]); + } + context.lineDashOffset = (lineWidth + spaceWidth) * index; + strokeRectWithRotation( + context, + elementX1 - linePadding, + elementY1 - linePadding, + elementWidth + linePadding * 2, + elementHeight + linePadding * 2, + cx, + cy, + angle, + ); + } + context.restore(); +}; + +const renderBindingHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, +) => { + const renderHighlight = Array.isArray(suggestedBinding) + ? renderBindingHighlightForSuggestedPointBinding + : renderBindingHighlightForBindableElement; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderHighlight(context, suggestedBinding as any, elementsMap); + + context.restore(); +}; + const renderFrameHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, frame: NonDeleted, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const width = x2 - x1; const height = y2 - y1; @@ -1362,138 +445,494 @@ const renderElementsBoxHighlight = ( ); }; -const renderBindingHighlightForSuggestedPointBinding = ( +const renderLinearPointHandles = ( context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, + appState: InteractiveCanvasAppState, + element: NonDeleted, + elementsMap: RenderableElementsMap, ) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; - - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, + if (!appState.selectedLinearElement) { + return; + } + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = 1 / appState.zoom.value; + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, ); - context.strokeStyle = "rgba(0,0,0,0)"; - context.fillStyle = "rgba(0,0,0,.05)"; + const { POINT_HANDLE_SIZE } = LinearElementEditor; + const radius = appState.editingLinearElement + ? POINT_HANDLE_SIZE + : POINT_HANDLE_SIZE / 2; + points.forEach((point, idx) => { + const isSelected = + !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); - const pointIndices = - startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; - pointIndices.forEach((index) => { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - index, - ); - fillCircle(context, x, y, threshold); + renderSingleLinearPoint(context, appState, point, radius, isSelected); + }); + + //Rendering segment mid points + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ).filter((midPoint) => midPoint !== null) as Point[]; + + midPoints.forEach((segmentMidPoint) => { + if ( + appState?.selectedLinearElement?.segmentMidPointHoveredCoords && + LinearElementEditor.arePointsEqual( + segmentMidPoint, + appState.selectedLinearElement.segmentMidPointHoveredCoords, + ) + ) { + // The order of renderingSingleLinearPoint and highLight points is different + // inside vs outside editor as hover states are different, + // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the + // editor original point is visible and hover state is just an outer circle. + if (appState.editingLinearElement) { + renderSingleLinearPoint( + context, + appState, + segmentMidPoint, + radius, + false, + ); + highlightPoint(segmentMidPoint, context, appState); + } else { + highlightPoint(segmentMidPoint, context, appState); + renderSingleLinearPoint( + context, + appState, + segmentMidPoint, + radius, + false, + ); + } + } else if (appState.editingLinearElement || points.length === 2) { + renderSingleLinearPoint( + context, + appState, + segmentMidPoint, + POINT_HANDLE_SIZE / 2, + false, + true, + ); + } + }); + + context.restore(); +}; + +const renderTransformHandles = ( + context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, + appState: InteractiveCanvasAppState, + transformHandles: TransformHandles, + angle: number, +): void => { + Object.keys(transformHandles).forEach((key) => { + const transformHandle = transformHandles[key as TransformHandleType]; + if (transformHandle !== undefined) { + const [x, y, width, height] = transformHandle; + + context.save(); + context.lineWidth = 1 / appState.zoom.value; + if (renderConfig.selectionColor) { + context.strokeStyle = renderConfig.selectionColor; + } + if (key === "rotation") { + fillCircle(context, x + width / 2, y + height / 2, width / 2); + // prefer round corners if roundRect API is available + } else if (context.roundRect) { + context.beginPath(); + context.roundRect(x, y, width, height, 2 / appState.zoom.value); + context.fill(); + context.stroke(); + } else { + strokeRectWithRotation( + context, + x, + y, + width, + height, + x + width / 2, + y + height / 2, + angle, + true, // fill before stroke + ); + } + context.restore(); + } }); }; -let linkCanvasCache: any; -const renderLinkIcon = ( - element: NonDeletedExcalidrawElement, - context: CanvasRenderingContext2D, - appState: StaticCanvasAppState, -) => { - if (element.link && !appState.selectedElementIds[element.id]) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const [x, y, width, height] = getLinkHandleFromCoords( - [x1, y1, x2, y2], - element.angle, - appState, - ); - const centerX = x + width / 2; - const centerY = y + height / 2; - context.save(); - context.translate(appState.scrollX + centerX, appState.scrollY + centerY); - context.rotate(element.angle); +const _renderInteractiveScene = ({ + canvas, + elementsMap, + visibleElements, + selectedElements, + scale, + appState, + renderConfig, +}: InteractiveSceneRenderConfig) => { + if (canvas === null) { + return { atLeastOneVisibleElement: false, elementsMap }; + } - if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { - linkCanvasCache = document.createElement("canvas"); - linkCanvasCache.zoom = appState.zoom.value; - linkCanvasCache.width = - width * window.devicePixelRatio * appState.zoom.value; - linkCanvasCache.height = - height * window.devicePixelRatio * appState.zoom.value; - const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; - linkCanvasCacheContext.scale( - window.devicePixelRatio * appState.zoom.value, - window.devicePixelRatio * appState.zoom.value, + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + }); + + // Apply zoom + context.save(); + context.scale(appState.zoom.value, appState.zoom.value); + + let editingLinearElement: NonDeleted | undefined = + undefined; + + visibleElements.forEach((element) => { + // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to + // ShapeCache returns empty hence making sure that we get the + // correct element from visible elements + if (appState.editingLinearElement?.elementId === element.id) { + if (element) { + editingLinearElement = element as NonDeleted; + } + } + }); + + if (editingLinearElement) { + renderLinearPointHandles( + context, + appState, + editingLinearElement, + elementsMap, + ); + } + + // Paint selection element + if (appState.selectionElement) { + try { + renderSelectionElement(appState.selectionElement, context, appState); + } catch (error: any) { + console.error(error); + } + } + + if (appState.isBindingEnabled) { + appState.suggestedBindings + .filter((binding) => binding != null) + .forEach((suggestedBinding) => { + renderBindingHighlight( + context, + appState, + suggestedBinding!, + elementsMap, + ); + }); + } + + if (appState.frameToHighlight) { + renderFrameHighlight( + context, + appState, + appState.frameToHighlight, + elementsMap, + ); + } + + if (appState.elementsToHighlight) { + renderElementsBoxHighlight(context, appState, appState.elementsToHighlight); + } + + const isFrameSelected = selectedElements.some((element) => + isFrameLikeElement(element), + ); + + // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to + // ShapeCache returns empty hence making sure that we get the + // correct element from visible elements + if ( + selectedElements.length === 1 && + appState.editingLinearElement?.elementId === selectedElements[0].id + ) { + renderLinearPointHandles( + context, + appState, + selectedElements[0] as NonDeleted, + elementsMap, + ); + } + + if ( + appState.selectedLinearElement && + appState.selectedLinearElement.hoverPointIndex >= 0 + ) { + renderLinearElementPointHighlight(context, appState, elementsMap); + } + // Paint selected elements + if (!appState.multiElement && !appState.editingLinearElement) { + const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); + + const isSingleLinearElementSelected = + selectedElements.length === 1 && isLinearElement(selectedElements[0]); + // render selected linear element points + if ( + isSingleLinearElementSelected && + appState.selectedLinearElement?.elementId === selectedElements[0].id && + !selectedElements[0].locked + ) { + renderLinearPointHandles( + context, + appState, + selectedElements[0] as ExcalidrawLinearElement, + elementsMap, ); - linkCanvasCacheContext.fillStyle = "#fff"; - linkCanvasCacheContext.fillRect(0, 0, width, height); - linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); - linkCanvasCacheContext.restore(); - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, + } + const selectionColor = renderConfig.selectionColor || oc.black; + + if (showBoundingBox) { + // Optimisation for finding quickly relevant element ids + const locallySelectedIds = arrayToMap(selectedElements); + + const selections: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }[] = []; + + for (const element of elementsMap.values()) { + const selectionColors = []; + // local user + if ( + locallySelectedIds.has(element.id) && + !isSelectedViaGroup(appState, element) + ) { + selectionColors.push(selectionColor); + } + // remote users + const remoteClients = renderConfig.remoteSelectedElementIds.get( + element.id, + ); + if (remoteClients) { + selectionColors.push( + ...remoteClients.map((socketId) => { + const background = getClientColor( + socketId, + appState.collaborators.get(socketId), + ); + return background; + }), + ); + } + + if (selectionColors.length) { + const [elementX1, elementY1, elementX2, elementY2, cx, cy] = + getElementAbsoluteCoords(element, elementsMap, true); + selections.push({ + angle: element.angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + dashed: !!remoteClients, + cx, + cy, + activeEmbeddable: + appState.activeEmbeddable?.element === element && + appState.activeEmbeddable.state === "active", + }); + } + } + + const addSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elementsMap, groupId); + const [elementX1, elementY1, elementX2, elementY2] = + getCommonBounds(groupElements); + selections.push({ + angle: 0, + elementX1, + elementX2, + elementY1, + elementY2, + selectionColors: [oc.black], + dashed: true, + cx: elementX1 + (elementX2 - elementX1) / 2, + cy: elementY1 + (elementY2 - elementY1) / 2, + activeEmbeddable: false, + }); + }; + + for (const groupId of getSelectedGroupIds(appState)) { + // TODO: support multiplayer selected group IDs + addSelectionForGroupId(groupId); + } + + if (appState.editingGroupId) { + addSelectionForGroupId(appState.editingGroupId); + } + + selections.forEach((selection) => + renderSelectionBorder(context, appState, selection), ); - } else { - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, + } + // Paint resize transformHandles + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + if (selectedElements.length === 1) { + context.fillStyle = oc.white; + const transformHandles = getTransformHandles( + selectedElements[0], + appState.zoom, + elementsMap, + "mouse", // when we render we don't know which pointer type so use mouse, ); + if (!appState.viewModeEnabled && showBoundingBox) { + renderTransformHandles( + context, + renderConfig, + appState, + transformHandles, + selectedElements[0].angle, + ); + } + } else if (selectedElements.length > 1 && !appState.isRotating) { + const dashedLinePadding = + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + context.fillStyle = oc.white; + const [x1, y1, x2, y2] = getCommonBounds(selectedElements); + const initialLineDash = context.getLineDash(); + context.setLineDash([2 / appState.zoom.value]); + const lineWidth = context.lineWidth; + context.lineWidth = 1 / appState.zoom.value; + context.strokeStyle = selectionColor; + strokeRectWithRotation( + context, + x1 - dashedLinePadding, + y1 - dashedLinePadding, + x2 - x1 + dashedLinePadding * 2, + y2 - y1 + dashedLinePadding * 2, + (x1 + x2) / 2, + (y1 + y2) / 2, + 0, + ); + context.lineWidth = lineWidth; + context.setLineDash(initialLineDash); + const transformHandles = getTransformHandlesFromCoords( + [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], + 0, + appState.zoom, + "mouse", + isFrameSelected + ? OMIT_SIDES_FOR_FRAME + : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + ); + if (selectedElements.some((element) => !element.locked)) { + renderTransformHandles( + context, + renderConfig, + appState, + transformHandles, + 0, + ); + } } context.restore(); } -}; -// This should be only called for exporting purposes -export const renderSceneToSvg = ( - elements: readonly NonDeletedExcalidrawElement[], - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - renderConfig: SVGRenderConfig, -) => { - if (!svgRoot) { - return; + renderSnaps(context, appState); + + // Reset zoom + context.restore(); + + renderRemoteCursors({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, + }); + + // Paint scrollbars + let scrollBars; + if (renderConfig.renderScrollbars) { + scrollBars = getScrollBars( + visibleElements, + normalizedWidth, + normalizedHeight, + appState, + ); + + context.save(); + context.fillStyle = SCROLLBAR_COLOR; + context.strokeStyle = "rgba(255,255,255,0.8)"; + [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => { + if (scrollBar) { + roundRect( + context, + scrollBar.x, + scrollBar.y, + scrollBar.width, + scrollBar.height, + SCROLLBAR_WIDTH / 2, + ); + } + }); + context.restore(); } - // render elements - elements - .filter((el) => !isIframeLikeOrItsLabel(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); - - // render embeddables on top - elements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); + return { + scrollBars, + atLeastOneVisibleElement: visibleElements.length > 0, + elementsMap, + }; +}; + +/** throttled to animation framerate */ +export const renderInteractiveSceneThrottled = throttleRAF( + (config: InteractiveSceneRenderConfig) => { + const ret = _renderInteractiveScene(config); + config.callback?.(ret); + }, + { trailing: true }, +); + +/** + * Interactive scene is the ui-canvas where we render bounding boxes, selections + * and other ui stuff. + */ +export const renderInteractiveScene = < + U extends typeof _renderInteractiveScene, + T extends boolean = false, +>( + renderConfig: InteractiveSceneRenderConfig, + throttle?: T, +): T extends true ? void : ReturnType => { + if (throttle) { + renderInteractiveSceneThrottled(renderConfig); + return undefined as T extends true ? void : ReturnType; + } + const ret = _renderInteractiveScene(renderConfig); + renderConfig.callback(ret); + return ret as T extends true ? void : ReturnType; }; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 2617d4694..78b558194 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -5,6 +5,9 @@ import { ExcalidrawFreeDrawElement, ExcalidrawImageElement, ExcalidrawTextElementWithContainer, + ExcalidrawFrameLikeElement, + NonDeletedSceneElementsMap, + ElementsMap, } from "../element/types"; import { isTextElement, @@ -17,33 +20,28 @@ import { } from "../element/typeChecks"; import { getElementAbsoluteCoords } from "../element/bounds"; import type { RoughCanvas } from "roughjs/bin/canvas"; -import type { Drawable } from "roughjs/bin/core"; -import type { RoughSVG } from "roughjs/bin/svg"; -import { SVGRenderConfig, StaticCanvasRenderConfig } from "../scene/types"; import { - distance, - getFontString, - getFontFamilyString, - isRTL, - isTestEnv, -} from "../utils"; -import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; + StaticCanvasRenderConfig, + RenderableElementsMap, +} from "../scene/types"; +import { distance, getFontString, isRTL } from "../utils"; +import { getCornerRadius, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import { AppState, StaticCanvasAppState, - BinaryFiles, Zoom, InteractiveCanvasAppState, + ElementsPendingErasure, } from "../types"; import { getDefaultAppState } from "../appState"; import { BOUND_TEXT_PADDING, + ELEMENT_READY_TO_ERASE_OPACITY, FRAME_STYLE, - MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, - SVG_NS, + THEME, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { @@ -53,21 +51,19 @@ import { getLineHeightInPx, getBoundTextMaxHeight, getBoundTextMaxWidth, + getVerticalOffset, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { - createPlaceholderEmbeddableLabel, - getEmbedLink, -} from "../element/embeddable"; + import { getContainingFrame } from "../frame"; -import { normalizeLink, toValidURL } from "../data/url"; import { ShapeCache } from "../scene/ShapeCache"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original // color scheme (it's still not quite there and the colors look slightly // desatured, alas...) -const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)"; +export const IMAGE_INVERT_FILTER = + "invert(100%) hue-rotate(180deg) saturate(1.25)"; const defaultAppState = getDefaultAppState(); @@ -84,7 +80,7 @@ const shouldResetImageFilter = ( appState: StaticCanvasAppState, ) => { return ( - appState.theme === "dark" && + appState.theme === THEME.DARK && isInitializedImageElement(element) && !isPendingImageElement(element, renderConfig) && renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg @@ -94,6 +90,27 @@ const shouldResetImageFilter = ( const getCanvasPadding = (element: ExcalidrawElement) => element.type === "freedraw" ? element.strokeWidth * 12 : 20; +export const getRenderOpacity = ( + element: ExcalidrawElement, + containingFrame: ExcalidrawFrameLikeElement | null, + elementsPendingErasure: ElementsPendingErasure, +) => { + // multiplying frame opacity with element opacity to combine them + // (e.g. frame 50% and element 50% opacity should result in 25% opacity) + let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000; + + // if pending erasure, multiply again to combine further + // (so that erasing always results in lower opacity than original) + if ( + elementsPendingErasure.has(element.id) || + (containingFrame && elementsPendingErasure.has(containingFrame.id)) + ) { + opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100; + } + + return opacity; +}; + export interface ExcalidrawElementWithCanvas { element: ExcalidrawElement | ExcalidrawTextElement; canvas: HTMLCanvasElement; @@ -108,6 +125,7 @@ export interface ExcalidrawElementWithCanvas { const cappedElementCanvasSize = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, zoom: Zoom, ): { width: number; @@ -126,7 +144,7 @@ const cappedElementCanvasSize = ( const padding = getCanvasPadding(element); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const elementWidth = isLinearElement(element) || isFreeDrawElement(element) ? distance(x1, x2) @@ -162,6 +180,7 @@ const cappedElementCanvasSize = ( const generateElementCanvas = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, @@ -170,7 +189,11 @@ const generateElementCanvas = ( const context = canvas.getContext("2d")!; const padding = getCanvasPadding(element); - const { width, height, scale } = cappedElementCanvasSize(element, zoom); + const { width, height, scale } = cappedElementCanvasSize( + element, + elementsMap, + zoom, + ); canvas.width = width; canvas.height = height; @@ -179,7 +202,7 @@ const generateElementCanvas = ( let canvasOffsetY = 0; if (isLinearElement(element) || isFreeDrawElement(element)) { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); canvasOffsetX = element.x > x1 @@ -219,8 +242,10 @@ const generateElementCanvas = ( zoomValue: zoom.value, canvasOffsetX, canvasOffsetY, - boundTextElementVersion: getBoundTextElement(element)?.version || null, - containingFrameOpacity: getContainingFrame(element)?.opacity || 100, + boundTextElementVersion: + getBoundTextElement(element, elementsMap)?.version || null, + containingFrameOpacity: + getContainingFrame(element, elementsMap)?.opacity || 100, }; }; @@ -269,8 +294,6 @@ const drawElementOnCanvas = ( renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { - context.globalAlpha = - ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; switch (element.type) { case "rectangle": case "iframe": @@ -315,6 +338,17 @@ const drawElementOnCanvas = ( ? renderConfig.imageCache.get(element.fileId)?.image : undefined; if (img != null && !(img instanceof Promise)) { + if (element.roundness && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + getCornerRadius(Math.min(element.width, element.height), element), + ); + context.clip(); + } context.drawImage( img, 0 /* hardcoded for the selection box*/, @@ -351,16 +385,23 @@ const drawElementOnCanvas = ( : element.textAlign === "right" ? element.width : 0; + const lineHeightPx = getLineHeightInPx( element.fontSize, element.lineHeight, ); - const verticalOffset = element.height - element.baseline; + + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, - (index + 1) * lineHeightPx - verticalOffset, + index * lineHeightPx + verticalOffset, ); } context.restore(); @@ -372,7 +413,6 @@ const drawElementOnCanvas = ( } } } - context.globalAlpha = 1; }; export const elementWithCanvasCache = new WeakMap< @@ -382,6 +422,7 @@ export const elementWithCanvasCache = new WeakMap< const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { @@ -391,8 +432,11 @@ const generateElementWithCanvas = ( prevElementWithCanvas && prevElementWithCanvas.zoomValue !== zoom.value && !appState?.shouldCacheIgnoreZoom; - const boundTextElementVersion = getBoundTextElement(element)?.version || null; - const containingFrameOpacity = getContainingFrame(element)?.opacity || 100; + const boundTextElementVersion = + getBoundTextElement(element, elementsMap)?.version || null; + + const containingFrameOpacity = + getContainingFrame(element, elementsMap)?.opacity || 100; if ( !prevElementWithCanvas || @@ -403,6 +447,7 @@ const generateElementWithCanvas = ( ) { const elementWithCanvas = generateElementCanvas( element, + elementsMap, zoom, renderConfig, appState, @@ -420,11 +465,12 @@ const drawElementFromCanvas = ( context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, + allElementsMap: NonDeletedSceneElementsMap, ) => { const element = elementWithCanvas.element; const padding = getCanvasPadding(element); const zoom = elementWithCanvas.scale; - let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); // Free draw elements will otherwise "shuffle" as the min x and y change if (isFreeDrawElement(element)) { @@ -439,7 +485,8 @@ const drawElementFromCanvas = ( context.save(); context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); - const boundTextElement = getBoundTextElement(element); + + const boundTextElement = getBoundTextElement(element, allElementsMap); if (isArrowElement(element) && boundTextElement) { const tempCanvas = document.createElement("canvas"); @@ -468,8 +515,10 @@ const drawElementFromCanvas = ( elementWithCanvas.canvas.height, ); - const [, , , , boundTextCx, boundTextCy] = - getElementAbsoluteCoords(boundTextElement); + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + allElementsMap, + ); tempCanvasContext.rotate(-element.angle); @@ -486,7 +535,6 @@ const drawElementFromCanvas = ( offsetY - padding * zoom; tempCanvasContext.translate(-shiftX, -shiftY); - // Clear the bound text area tempCanvasContext.clearRect( -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * @@ -548,6 +596,7 @@ const drawElementFromCanvas = ( ) { const textElement = getBoundTextElement( element, + allElementsMap, ) as ExcalidrawTextElementWithContainer; const coords = getContainerCoords(element); context.strokeStyle = "#c92a2a"; @@ -555,7 +604,7 @@ const drawElementFromCanvas = ( context.strokeRect( (coords.x + appState.scrollX) * window.devicePixelRatio, (coords.y + appState.scrollY) * window.devicePixelRatio, - getBoundTextMaxWidth(element) * window.devicePixelRatio, + getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio, getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, ); } @@ -590,11 +639,19 @@ export const renderSelectionElement = ( export const renderElement = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + allElementsMap: NonDeletedSceneElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { + context.globalAlpha = getRenderOpacity( + element, + getContainingFrame(element, elementsMap), + renderConfig.elementsPendingErasure, + ); + switch (element.type) { case "magicframe": case "frame": { @@ -612,7 +669,7 @@ export const renderElement = ( // TODO change later to only affect AI frames if (isMagicFrameElement(element)) { context.strokeStyle = - appState.theme === "light" ? "#7affd7" : "#1d8264"; + appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264"; } if (FRAME_STYLE.radius && context.roundRect) { @@ -641,7 +698,7 @@ export const renderElement = ( ShapeCache.generateElementShape(element, null); if (renderConfig.isExporting) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; const shiftX = (x2 - x1) / 2 - (element.x - x1); @@ -655,6 +712,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, + elementsMap, renderConfig, appState, ); @@ -663,6 +721,7 @@ export const renderElement = ( context, renderConfig, appState, + allElementsMap, ); } @@ -682,18 +741,19 @@ export const renderElement = ( // rely on existing shapes ShapeCache.generateElementShape(element, renderConfig); if (renderConfig.isExporting) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftY = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1); shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1); @@ -705,7 +765,7 @@ export const renderElement = ( if (shouldResetImageFilter(element, renderConfig, appState)) { context.filter = "none"; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (isArrowElement(element) && boundTextElement) { const tempCanvas = document.createElement("canvas"); @@ -749,8 +809,10 @@ export const renderElement = ( tempCanvasContext.rotate(-element.angle); // Shift the canvas to center of bound text - const [, , , , boundTextCx, boundTextCy] = - getElementAbsoluteCoords(boundTextElement); + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); const boundTextShiftX = (x1 + x2) / 2 - boundTextCx; const boundTextShiftY = (y1 + y2) / 2 - boundTextCy; tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY); @@ -788,6 +850,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, + elementsMap, renderConfig, appState, ); @@ -819,6 +882,7 @@ export const renderElement = ( context, renderConfig, appState, + allElementsMap, ); // reset @@ -831,527 +895,8 @@ export const renderElement = ( throw new Error(`Unimplemented type ${element.type}`); } } -}; -const roughSVGDrawWithPrecision = ( - rsvg: RoughSVG, - drawable: Drawable, - precision?: number, -) => { - if (typeof precision === "undefined") { - return rsvg.draw(drawable); - } - const pshape: Drawable = { - sets: drawable.sets, - shape: drawable.shape, - options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, - }; - return rsvg.draw(pshape); -}; - -const maybeWrapNodesInFrameClipPath = ( - element: NonDeletedExcalidrawElement, - root: SVGElement, - nodes: SVGElement[], - frameRendering: AppState["frameRendering"], -) => { - if (!frameRendering.enabled || !frameRendering.clip) { - return null; - } - const frame = getContainingFrame(element); - if (frame) { - const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); - g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); - nodes.forEach((node) => g.appendChild(node)); - return g; - } - - return null; -}; - -export const renderElementToSvg = ( - element: NonDeletedExcalidrawElement, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - offsetX: number, - offsetY: number, - renderConfig: SVGRenderConfig, -) => { - const offset = { x: offsetX, y: offsetY }; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - let cx = (x2 - x1) / 2 - (element.x - x1); - let cy = (y2 - y1) / 2 - (element.y - y1); - if (isTextElement(element)) { - const container = getContainerElement(element); - if (isArrowElement(container)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); - - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - container, - element as ExcalidrawTextElementWithContainer, - ); - cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); - cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); - offsetX = offsetX + boundTextCoords.x - element.x; - offsetY = offsetY + boundTextCoords.y - element.y; - } - } - const degree = (180 * element.angle) / Math.PI; - - // element to append node to, most of the time svgRoot - let root = svgRoot; - - // if the element has a link, create an anchor tag and make that the new root - if (element.link) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link)); - root.appendChild(anchorTag); - root = anchorTag; - } - - const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { - if (isTestEnv()) { - node.setAttribute("data-id", element.id); - } - root.appendChild(node); - }; - - const opacity = - ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; - - switch (element.type) { - case "selection": { - // Since this is used only during editing experience, which is canvas based, - // this should not happen - throw new Error("Selection rendering is not supported for SVG"); - } - case "rectangle": - case "diamond": - case "ellipse": { - const shape = ShapeCache.generateElementShape(element, null); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - ); - - addToRoot(g || node, element); - break; - } - case "iframe": - case "embeddable": { - // render placeholder rectangle - const shape = ShapeCache.generateElementShape(element, renderConfig); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - const opacity = element.opacity / 100; - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - addToRoot(node, element); - - const label: ExcalidrawElement = - createPlaceholderEmbeddableLabel(element); - renderElementToSvg( - label, - rsvg, - root, - files, - label.x + offset.x - element.x, - label.y + offset.y - element.y, - renderConfig, - ); - - // render embeddable element + iframe - const embeddableNode = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - embeddableNode.setAttribute("stroke-linecap", "round"); - embeddableNode.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - while (embeddableNode.firstChild) { - embeddableNode.removeChild(embeddableNode.firstChild); - } - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - - const embedLink = getEmbedLink(toValidURL(element.link || "")); - - // if rendering embeddables explicitly disabled or - // embedding documents via srcdoc (which doesn't seem to work for SVGs) - // replace with a link instead - if ( - renderConfig.renderEmbeddables === false || - embedLink?.type === "document" - ) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link || "")); - anchorTag.setAttribute("target", "_blank"); - anchorTag.setAttribute("rel", "noopener noreferrer"); - anchorTag.style.borderRadius = `${radius}px`; - - embeddableNode.appendChild(anchorTag); - } else { - const foreignObject = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "foreignObject", - ); - foreignObject.style.width = `${element.width}px`; - foreignObject.style.height = `${element.height}px`; - foreignObject.style.border = "none"; - const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); - div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); - div.style.width = "100%"; - div.style.height = "100%"; - const iframe = div.ownerDocument!.createElement("iframe"); - iframe.src = embedLink?.link ?? ""; - iframe.style.width = "100%"; - iframe.style.height = "100%"; - iframe.style.border = "none"; - iframe.style.borderRadius = `${radius}px`; - iframe.style.top = "0"; - iframe.style.left = "0"; - iframe.allowFullscreen = true; - div.appendChild(iframe); - foreignObject.appendChild(div); - - embeddableNode.appendChild(foreignObject); - } - addToRoot(embeddableNode, element); - break; - } - case "line": - case "arrow": { - const boundText = getBoundTextElement(element); - const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); - if (boundText) { - maskPath.setAttribute("id", `mask-${element.id}`); - const maskRectVisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - offsetX = offsetX || 0; - offsetY = offsetY || 0; - maskRectVisible.setAttribute("x", "0"); - maskRectVisible.setAttribute("y", "0"); - maskRectVisible.setAttribute("fill", "#fff"); - maskRectVisible.setAttribute( - "width", - `${element.width + 100 + offsetX}`, - ); - maskRectVisible.setAttribute( - "height", - `${element.height + 100 + offsetY}`, - ); - - maskPath.appendChild(maskRectVisible); - const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - element, - boundText, - ); - - const maskX = offsetX + boundTextCoords.x - element.x; - const maskY = offsetY + boundTextCoords.y - element.y; - - maskRectInvisible.setAttribute("x", maskX.toString()); - maskRectInvisible.setAttribute("y", maskY.toString()); - maskRectInvisible.setAttribute("fill", "#000"); - maskRectInvisible.setAttribute("width", `${boundText.width}`); - maskRectInvisible.setAttribute("height", `${boundText.height}`); - maskRectInvisible.setAttribute("opacity", "1"); - maskPath.appendChild(maskRectInvisible); - } - const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (boundText) { - group.setAttribute("mask", `url(#mask-${element.id})`); - } - group.setAttribute("stroke-linecap", "round"); - - const shapes = ShapeCache.generateElementShape(element, renderConfig); - shapes.forEach((shape) => { - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - if ( - element.type === "line" && - isPathALoop(element.points) && - element.backgroundColor !== "transparent" - ) { - node.setAttribute("fill-rule", "evenodd"); - } - group.appendChild(node); - }); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [group, maskPath], - renderConfig.frameRendering, - ); - if (g) { - addToRoot(g, element); - root.appendChild(g); - } else { - addToRoot(group, element); - root.append(maskPath); - } - break; - } - case "freedraw": { - const backgroundFillShape = ShapeCache.generateElementShape( - element, - renderConfig, - ); - const node = backgroundFillShape - ? roughSVGDrawWithPrecision( - rsvg, - backgroundFillShape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ) - : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - node.setAttribute("stroke", "none"); - const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); - path.setAttribute("fill", element.strokeColor); - path.setAttribute("d", getFreeDrawSvgPath(element)); - node.appendChild(path); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - ); - - addToRoot(g || node, element); - break; - } - case "image": { - const width = Math.round(element.width); - const height = Math.round(element.height); - const fileData = - isInitializedImageElement(element) && files[element.fileId]; - if (fileData) { - const symbolId = `image-${fileData.id}`; - let symbol = svgRoot.querySelector(`#${symbolId}`); - if (!symbol) { - symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); - symbol.id = symbolId; - - const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); - - image.setAttribute("width", "100%"); - image.setAttribute("height", "100%"); - image.setAttribute("href", fileData.dataURL); - - symbol.appendChild(image); - - root.prepend(symbol); - } - - const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); - use.setAttribute("href", `#${symbolId}`); - - // in dark theme, revert the image color filter - if ( - renderConfig.exportWithDarkMode && - fileData.mimeType !== MIME_TYPES.svg - ) { - use.setAttribute("filter", IMAGE_INVERT_FILTER); - } - - use.setAttribute("width", `${width}`); - use.setAttribute("height", `${height}`); - use.setAttribute("opacity", `${opacity}`); - - // We first apply `scale` transforms (horizontal/vertical mirroring) - // on the element, then apply translation and rotation - // on the element which wraps the . - // Doing this separately is a quick hack to to work around compositing - // the transformations correctly (the transform-origin was not being - // applied correctly). - if (element.scale[0] !== 1 || element.scale[1] !== 1) { - const translateX = element.scale[0] !== 1 ? -width : 0; - const translateY = element.scale[1] !== 1 ? -height : 0; - use.setAttribute( - "transform", - `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, - ); - } - - const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - g.appendChild(use); - g.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - const clipG = maybeWrapNodesInFrameClipPath( - element, - root, - [g], - renderConfig.frameRendering, - ); - addToRoot(clipG || g, element); - } - break; - } - // frames are not rendered and only acts as a container - case "frame": - case "magicframe": { - if ( - renderConfig.frameRendering.enabled && - renderConfig.frameRendering.outline - ) { - const rect = document.createElementNS(SVG_NS, "rect"); - - rect.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - rect.setAttribute("width", `${element.width}px`); - rect.setAttribute("height", `${element.height}px`); - // Rounded corners - rect.setAttribute("rx", FRAME_STYLE.radius.toString()); - rect.setAttribute("ry", FRAME_STYLE.radius.toString()); - - rect.setAttribute("fill", "none"); - rect.setAttribute("stroke", FRAME_STYLE.strokeColor); - rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); - - addToRoot(rect, element); - } - break; - } - default: { - if (isTextElement(element)) { - const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeightPx = getLineHeightInPx( - element.fontSize, - element.lineHeight, - ); - const horizontalOffset = - element.textAlign === "center" - ? element.width / 2 - : element.textAlign === "right" - ? element.width - : 0; - const direction = isRTL(element.text) ? "rtl" : "ltr"; - const textAnchor = - element.textAlign === "center" - ? "middle" - : element.textAlign === "right" || direction === "rtl" - ? "end" - : "start"; - for (let i = 0; i < lines.length; i++) { - const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); - text.textContent = lines[i]; - text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeightPx}`); - text.setAttribute("font-family", getFontFamilyString(element)); - text.setAttribute("font-size", `${element.fontSize}px`); - text.setAttribute("fill", element.strokeColor); - text.setAttribute("text-anchor", textAnchor); - text.setAttribute("style", "white-space: pre;"); - text.setAttribute("direction", direction); - text.setAttribute("dominant-baseline", "text-before-edge"); - node.appendChild(text); - } - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - ); - - addToRoot(g || node, element); - } else { - // @ts-ignore - throw new Error(`Unimplemented type ${element.type}`); - } - } - } + context.globalAlpha = 1; }; export const pathsCache = new WeakMap([]); diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts index 220c3e7db..79775b37d 100644 --- a/packages/excalidraw/renderer/renderSnaps.ts +++ b/packages/excalidraw/renderer/renderSnaps.ts @@ -1,3 +1,4 @@ +import { THEME } from "../constants"; import { PointSnapLine, PointerSnapLine } from "../snapping"; import { InteractiveCanvasAppState, Point } from "../types"; @@ -18,7 +19,7 @@ export const renderSnaps = ( // 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 + appState.theme === 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 diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts new file mode 100644 index 000000000..c2b5f218a --- /dev/null +++ b/packages/excalidraw/renderer/staticScene.ts @@ -0,0 +1,396 @@ +import { FRAME_STYLE } from "../constants"; +import { getElementAbsoluteCoords } from "../element"; + +import { + elementOverlapsWithFrame, + getTargetFrame, + isElementInFrame, +} from "../frame"; +import { + isEmbeddableElement, + isIframeLikeElement, + isTextElement, +} from "../element/typeChecks"; +import { renderElement } from "../renderer/renderElement"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; +import { StaticCanvasAppState, Zoom } from "../types"; +import { + ElementsMap, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { + StaticCanvasRenderConfig, + StaticSceneRenderConfig, +} from "../scene/types"; +import { + EXTERNAL_LINK_IMG, + getLinkHandleFromCoords, +} from "../components/hyperlink/helpers"; +import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; +import { throttleRAF } from "../utils"; +import { getBoundTextElement } from "../element/textElement"; + +const strokeGrid = ( + context: CanvasRenderingContext2D, + gridSize: number, + scrollX: number, + scrollY: number, + zoom: Zoom, + width: number, + height: number, +) => { + const BOLD_LINE_FREQUENCY = 5; + + enum GridLineColor { + Bold = "#cccccc", + Regular = "#e5e5e5", + } + + const offsetX = + -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); + const offsetY = + -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); + + const lineWidth = Math.min(1 / zoom.value, 1); + + const spaceWidth = 1 / zoom.value; + const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; + + context.save(); + context.lineWidth = lineWidth; + + for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { + const isBold = + Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(x, offsetY - gridSize); + context.lineTo(x, offsetY + height + gridSize * 2); + context.stroke(); + } + for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { + const isBold = + Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(offsetX - gridSize, y); + context.lineTo(offsetX + width + gridSize * 2, y); + context.stroke(); + } + context.restore(); +}; + +const frameClip = ( + frame: ExcalidrawFrameLikeElement, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); + context.beginPath(); + if (context.roundRect) { + context.roundRect( + 0, + 0, + frame.width, + frame.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(0, 0, frame.width, frame.height); + } + context.clip(); + context.translate( + -(frame.x + appState.scrollX), + -(frame.y + appState.scrollY), + ); +}; + +let linkCanvasCache: any; +const renderLinkIcon = ( + element: NonDeletedExcalidrawElement, + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState, + elementsMap: ElementsMap, +) => { + if (element.link && !appState.selectedElementIds[element.id]) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [x, y, width, height] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const centerX = x + width / 2; + const centerY = y + height / 2; + context.save(); + context.translate(appState.scrollX + centerX, appState.scrollY + centerY); + context.rotate(element.angle); + + if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { + linkCanvasCache = document.createElement("canvas"); + linkCanvasCache.zoom = appState.zoom.value; + linkCanvasCache.width = + width * window.devicePixelRatio * appState.zoom.value; + linkCanvasCache.height = + height * window.devicePixelRatio * appState.zoom.value; + const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; + linkCanvasCacheContext.scale( + window.devicePixelRatio * appState.zoom.value, + window.devicePixelRatio * appState.zoom.value, + ); + linkCanvasCacheContext.fillStyle = "#fff"; + linkCanvasCacheContext.fillRect(0, 0, width, height); + linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); + linkCanvasCacheContext.restore(); + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } else { + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } + context.restore(); + } +}; +const _renderStaticScene = ({ + canvas, + rc, + elementsMap, + allElementsMap, + visibleElements, + scale, + appState, + renderConfig, +}: StaticSceneRenderConfig) => { + if (canvas === null) { + return; + } + + const { renderGrid = true, isExporting } = renderConfig; + + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme: appState.theme, + isExporting, + viewBackgroundColor: appState.viewBackgroundColor, + }); + + // Apply zoom + context.scale(appState.zoom.value, appState.zoom.value); + + // Grid + if (renderGrid && appState.gridSize) { + strokeGrid( + context, + appState.gridSize, + appState.scrollX, + appState.scrollY, + appState.zoom, + normalizedWidth / appState.zoom.value, + normalizedHeight / appState.zoom.value, + ); + } + + const groupsToBeAddedToFrame = new Set(); + + visibleElements.forEach((element) => { + if ( + element.groupIds.length > 0 && + appState.frameToHighlight && + appState.selectedElementIds[element.id] && + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || + element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) + ) { + element.groupIds.forEach((groupId) => + groupsToBeAddedToFrame.add(groupId), + ); + } + }); + + // Paint visible elements + visibleElements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + try { + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + isTextElement(element) && + element.containerId && + elementsMap.has(element.containerId) + ) { + // will be rendered with the container + return; + } + + context.save(); + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + const frame = getTargetFrame(element, elementsMap, appState); + + // TODO do we need to check isElementInFrame here? + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } else { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + + const boundTextElement = getBoundTextElement(element, allElementsMap); + if (boundTextElement) { + renderElement( + boundTextElement, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + + context.restore(); + + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + } catch (error: any) { + console.error(error); + } + }); + + // render embeddables on top + visibleElements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + try { + const render = () => { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + + if ( + isIframeLikeElement(element) && + (isExporting || + (isEmbeddableElement(element) && + renderConfig.embedsValidationStatus.get(element.id) !== + true)) && + element.width && + element.height + ) { + const label = createPlaceholderEmbeddableLabel(element); + renderElement( + label, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + }; + // - when exporting the whole canvas, we DO NOT apply clipping + // - when we are exporting a particular frame, apply clipping + // if the containing frame is not selected, apply clipping + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + render(); + context.restore(); + } else { + render(); + } + } catch (error: any) { + console.error(error); + } + }); +}; + +/** throttled to animation framerate */ +export const renderStaticSceneThrottled = throttleRAF( + (config: StaticSceneRenderConfig) => { + _renderStaticScene(config); + }, + { trailing: true }, +); + +/** + * Static scene is the non-ui canvas where we render elements. + */ +export const renderStaticScene = ( + renderConfig: StaticSceneRenderConfig, + throttle?: boolean, +) => { + if (throttle) { + renderStaticSceneThrottled(renderConfig); + return; + } + + _renderStaticScene(renderConfig); +}; diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts new file mode 100644 index 000000000..4758fb07c --- /dev/null +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -0,0 +1,682 @@ +import { Drawable } from "roughjs/bin/core"; +import { RoughSVG } from "roughjs/bin/svg"; +import { + FRAME_STYLE, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, +} from "../constants"; +import { normalizeLink, toValidURL } from "../data/url"; +import { getElementAbsoluteCoords } from "../element"; +import { + createPlaceholderEmbeddableLabel, + getEmbedLink, +} from "../element/embeddable"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + getBoundTextElement, + getContainerElement, + getLineHeightInPx, + getVerticalOffset, +} from "../element/textElement"; +import { + isArrowElement, + isIframeLikeElement, + isInitializedImageElement, + isTextElement, +} from "../element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getContainingFrame } from "../frame"; +import { getCornerRadius, isPathALoop } from "../math"; +import { ShapeCache } from "../scene/ShapeCache"; +import { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; +import { AppState, BinaryFiles } from "../types"; +import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; +import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; + +const roughSVGDrawWithPrecision = ( + rsvg: RoughSVG, + drawable: Drawable, + precision?: number, +) => { + if (typeof precision === "undefined") { + return rsvg.draw(drawable); + } + const pshape: Drawable = { + sets: drawable.sets, + shape: drawable.shape, + options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, + }; + return rsvg.draw(pshape); +}; + +const maybeWrapNodesInFrameClipPath = ( + element: NonDeletedExcalidrawElement, + root: SVGElement, + nodes: SVGElement[], + frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, +) => { + if (!frameRendering.enabled || !frameRendering.clip) { + return null; + } + const frame = getContainingFrame(element, elementsMap); + if (frame) { + const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); + g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); + nodes.forEach((node) => g.appendChild(node)); + return g; + } + + return null; +}; + +const renderElementToSvg = ( + element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + offsetX: number, + offsetY: number, + renderConfig: SVGRenderConfig, +) => { + const offset = { x: offsetX, y: offsetY }; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + let cx = (x2 - x1) / 2 - (element.x - x1); + let cy = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); + + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); + cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); + offsetX = offsetX + boundTextCoords.x - element.x; + offsetY = offsetY + boundTextCoords.y - element.y; + } + } + const degree = (180 * element.angle) / Math.PI; + + // element to append node to, most of the time svgRoot + let root = svgRoot; + + // if the element has a link, create an anchor tag and make that the new root + if (element.link) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link)); + root.appendChild(anchorTag); + root = anchorTag; + } + + const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { + if (isTestEnv()) { + node.setAttribute("data-id", element.id); + } + root.appendChild(node); + }; + + const opacity = + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; + + switch (element.type) { + case "selection": { + // Since this is used only during editing experience, which is canvas based, + // this should not happen + throw new Error("Selection rendering is not supported for SVG"); + } + case "rectangle": + case "diamond": + case "ellipse": { + const shape = ShapeCache.generateElementShape(element, null); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "iframe": + case "embeddable": { + // render placeholder rectangle + const shape = ShapeCache.generateElementShape(element, renderConfig); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + const opacity = element.opacity / 100; + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + addToRoot(node, element); + + const label: ExcalidrawElement = + createPlaceholderEmbeddableLabel(element); + renderElementToSvg( + label, + elementsMap, + rsvg, + root, + files, + label.x + offset.x - element.x, + label.y + offset.y - element.y, + renderConfig, + ); + + // render embeddable element + iframe + const embeddableNode = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + embeddableNode.setAttribute("stroke-linecap", "round"); + embeddableNode.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + while (embeddableNode.firstChild) { + embeddableNode.removeChild(embeddableNode.firstChild); + } + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + const embedLink = getEmbedLink(toValidURL(element.link || "")); + + // if rendering embeddables explicitly disabled or + // embedding documents via srcdoc (which doesn't seem to work for SVGs) + // replace with a link instead + if ( + renderConfig.renderEmbeddables === false || + embedLink?.type === "document" + ) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link || "")); + anchorTag.setAttribute("target", "_blank"); + anchorTag.setAttribute("rel", "noopener noreferrer"); + anchorTag.style.borderRadius = `${radius}px`; + + embeddableNode.appendChild(anchorTag); + } else { + const foreignObject = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "foreignObject", + ); + foreignObject.style.width = `${element.width}px`; + foreignObject.style.height = `${element.height}px`; + foreignObject.style.border = "none"; + const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); + div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + div.style.width = "100%"; + div.style.height = "100%"; + const iframe = div.ownerDocument!.createElement("iframe"); + iframe.src = embedLink?.link ?? ""; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + iframe.style.borderRadius = `${radius}px`; + iframe.style.top = "0"; + iframe.style.left = "0"; + iframe.allowFullscreen = true; + div.appendChild(iframe); + foreignObject.appendChild(div); + + embeddableNode.appendChild(foreignObject); + } + addToRoot(embeddableNode, element); + break; + } + case "line": + case "arrow": { + const boundText = getBoundTextElement(element, elementsMap); + const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + if (boundText) { + maskPath.setAttribute("id", `mask-${element.id}`); + const maskRectVisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + offsetX = offsetX || 0; + offsetY = offsetY || 0; + maskRectVisible.setAttribute("x", "0"); + maskRectVisible.setAttribute("y", "0"); + maskRectVisible.setAttribute("fill", "#fff"); + maskRectVisible.setAttribute( + "width", + `${element.width + 100 + offsetX}`, + ); + maskRectVisible.setAttribute( + "height", + `${element.height + 100 + offsetY}`, + ); + + maskPath.appendChild(maskRectVisible); + const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + element, + boundText, + elementsMap, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + maskRectInvisible.setAttribute("opacity", "1"); + maskPath.appendChild(maskRectInvisible); + } + const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (boundText) { + group.setAttribute("mask", `url(#mask-${element.id})`); + } + group.setAttribute("stroke-linecap", "round"); + + const shapes = ShapeCache.generateElementShape(element, renderConfig); + shapes.forEach((shape) => { + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + if ( + element.type === "line" && + isPathALoop(element.points) && + element.backgroundColor !== "transparent" + ) { + node.setAttribute("fill-rule", "evenodd"); + } + group.appendChild(node); + }); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [group, maskPath], + renderConfig.frameRendering, + elementsMap, + ); + if (g) { + addToRoot(g, element); + root.appendChild(g); + } else { + addToRoot(group, element); + root.append(maskPath); + } + break; + } + case "freedraw": { + const backgroundFillShape = ShapeCache.generateElementShape( + element, + renderConfig, + ); + const node = backgroundFillShape + ? roughSVGDrawWithPrecision( + rsvg, + backgroundFillShape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ) + : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + node.setAttribute("stroke", "none"); + const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", element.strokeColor); + path.setAttribute("d", getFreeDrawSvgPath(element)); + node.appendChild(path); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "image": { + const width = Math.round(element.width); + const height = Math.round(element.height); + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + if (fileData) { + const symbolId = `image-${fileData.id}`; + let symbol = svgRoot.querySelector(`#${symbolId}`); + if (!symbol) { + symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); + symbol.id = symbolId; + + const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); + + image.setAttribute("width", "100%"); + image.setAttribute("height", "100%"); + image.setAttribute("href", fileData.dataURL); + + symbol.appendChild(image); + + root.prepend(symbol); + } + + const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); + use.setAttribute("href", `#${symbolId}`); + + // in dark theme, revert the image color filter + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { + use.setAttribute("filter", IMAGE_INVERT_FILTER); + } + + use.setAttribute("width", `${width}`); + use.setAttribute("height", `${height}`); + use.setAttribute("opacity", `${opacity}`); + + // We first apply `scale` transforms (horizontal/vertical mirroring) + // on the element, then apply translation and rotation + // on the element which wraps the . + // Doing this separately is a quick hack to to work around compositing + // the transformations correctly (the transform-origin was not being + // applied correctly). + if (element.scale[0] !== 1 || element.scale[1] !== 1) { + const translateX = element.scale[0] !== 1 ? -width : 0; + const translateY = element.scale[1] !== 1 ? -height : 0; + use.setAttribute( + "transform", + `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, + ); + } + + const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + g.appendChild(use); + g.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + if (element.roundness) { + const clipPath = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "clipPath", + ); + clipPath.id = `image-clipPath-${element.id}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + clipRect.setAttribute("width", `${element.width}`); + clipRect.setAttribute("height", `${element.height}`); + clipRect.setAttribute("rx", `${radius}`); + clipRect.setAttribute("ry", `${radius}`); + clipPath.appendChild(clipRect); + addToRoot(clipPath, element); + + g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); + } + + const clipG = maybeWrapNodesInFrameClipPath( + element, + root, + [g], + renderConfig.frameRendering, + elementsMap, + ); + addToRoot(clipG || g, element); + } + break; + } + // frames are not rendered and only acts as a container + case "frame": + case "magicframe": { + if ( + renderConfig.frameRendering.enabled && + renderConfig.frameRendering.outline + ) { + const rect = document.createElementNS(SVG_NS, "rect"); + + rect.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + rect.setAttribute("width", `${element.width}px`); + rect.setAttribute("height", `${element.height}px`); + // Rounded corners + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + + rect.setAttribute("fill", "none"); + rect.setAttribute("stroke", FRAME_STYLE.strokeColor); + rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); + + addToRoot(rect, element); + } + break; + } + default: { + if (isTextElement(element)) { + const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + const horizontalOffset = + element.textAlign === "center" + ? element.width / 2 + : element.textAlign === "right" + ? element.width + : 0; + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + const direction = isRTL(element.text) ? "rtl" : "ltr"; + const textAnchor = + element.textAlign === "center" + ? "middle" + : element.textAlign === "right" || direction === "rtl" + ? "end" + : "start"; + for (let i = 0; i < lines.length; i++) { + const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); + text.textContent = lines[i]; + text.setAttribute("x", `${horizontalOffset}`); + text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`); + text.setAttribute("font-family", getFontFamilyString(element)); + text.setAttribute("font-size", `${element.fontSize}px`); + text.setAttribute("fill", element.strokeColor); + text.setAttribute("text-anchor", textAnchor); + text.setAttribute("style", "white-space: pre;"); + text.setAttribute("direction", direction); + text.setAttribute("dominant-baseline", "alphabetic"); + node.appendChild(text); + } + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + } else { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + } +}; + +export const renderSceneToSvg = ( + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + renderConfig: SVGRenderConfig, +) => { + if (!svgRoot) { + return; + } + + // render elements + elements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + if ( + isTextElement(element) && + element.containerId && + elementsMap.has(element.containerId) + ) { + // will be rendered with the container + return; + } + + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + renderElementToSvg( + boundTextElement, + elementsMap, + rsvg, + svgRoot, + files, + boundTextElement.x + renderConfig.offsetX, + boundTextElement.y + renderConfig.offsetY, + renderConfig, + ); + } + } catch (error: any) { + console.error(error); + } + } + }); + + // render embeddables on top + elements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); +}; diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index 05dddadc4..6691e90be 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -1,5 +1,6 @@ import { isTextElement, refreshTextDimensions } from "../element"; import { newElementWith } from "../element/mutateElement"; +import { getContainerElement } from "../element/textElement"; import { isBoundToContainer } from "../element/typeChecks"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { getFontString } from "../utils"; @@ -57,7 +58,11 @@ export class Fonts { ShapeCache.delete(element); didUpdate = true; return newElementWith(element, { - ...refreshTextDimensions(element), + ...refreshTextDimensions( + element, + getContainerElement(element, this.scene.getNonDeletedElementsMap()), + this.scene.getNonDeletedElementsMap(), + ), }); } return element; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 152224951..7970f8c1c 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -1,10 +1,16 @@ import { isElementInViewport } from "../element/sizeHelpers"; import { isImageElement } from "../element/typeChecks"; -import { NonDeletedExcalidrawElement } from "../element/types"; -import { cancelRender } from "../renderer/renderScene"; +import { + NonDeletedElementsMap, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene"; +import { renderStaticSceneThrottled } from "../renderer/staticScene"; + import { AppState } from "../types"; -import { memoize } from "../utils"; +import { memoize, toBrandedType } from "../utils"; import Scene from "./Scene"; +import { RenderableElementsMap } from "./types"; export class Renderer { private scene: Scene; @@ -15,7 +21,7 @@ export class Renderer { public getRenderableElements = (() => { const getVisibleCanvasElements = ({ - elements, + elementsMap, zoom, offsetLeft, offsetTop, @@ -24,7 +30,7 @@ export class Renderer { height, width, }: { - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: NonDeletedElementsMap; zoom: AppState["zoom"]; offsetLeft: AppState["offsetLeft"]; offsetTop: AppState["offsetTop"]; @@ -33,43 +39,61 @@ export class Renderer { height: AppState["height"]; width: AppState["width"]; }): readonly NonDeletedExcalidrawElement[] => { - return elements.filter((element) => - isElementInViewport(element, width, height, { - zoom, - offsetLeft, - offsetTop, - scrollX, - scrollY, - }), - ); + const visibleElements: NonDeletedExcalidrawElement[] = []; + for (const element of elementsMap.values()) { + if ( + isElementInViewport( + element, + width, + height, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }, + elementsMap, + ) + ) { + visibleElements.push(element); + } + } + return visibleElements; }; - const getCanvasElements = ({ - editingElement, + const getRenderableElements = ({ elements, + editingElement, pendingImageElementId, }: { elements: readonly NonDeletedExcalidrawElement[]; editingElement: AppState["editingElement"]; pendingImageElementId: AppState["pendingImageElementId"]; }) => { - return elements.filter((element) => { + const elementsMap = toBrandedType(new Map()); + + for (const element of elements) { if (isImageElement(element)) { if ( // => not placed on canvas yet (but in elements array) pendingImageElementId === element.id ) { - return false; + continue; } } + // we don't want to render text element that's being currently edited // (it's rendered on remote only) - return ( + if ( !editingElement || editingElement.type !== "text" || element.id !== editingElement.id - ); - }); + ) { + elementsMap.set(element.id, element); + } + } + return elementsMap; }; return memoize( @@ -100,14 +124,14 @@ export class Renderer { }) => { const elements = this.scene.getNonDeletedElements(); - const canvasElements = getCanvasElements({ + const elementsMap = getRenderableElements({ elements, editingElement, pendingImageElementId, }); const visibleElements = getVisibleCanvasElements({ - elements: canvasElements, + elementsMap, zoom, offsetLeft, offsetTop, @@ -117,7 +141,7 @@ export class Renderer { width, }); - return { canvasElements, visibleElements }; + return { elementsMap, visibleElements }; }, ); })(); @@ -125,7 +149,8 @@ export class Renderer { // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be // safe to break TS contract here (for upstream cases) public destroy() { - cancelRender(); + renderInteractiveSceneThrottled.cancel(); + renderStaticSceneThrottled.cancel(); this.getRenderableElements.clear(); } } diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 814638e7e..6be7d8704 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -3,14 +3,27 @@ import { NonDeletedExcalidrawElement, NonDeleted, ExcalidrawFrameLikeElement, + ElementsMapOrArray, + SceneElementsMap, + NonDeletedSceneElementsMap, + OrderedExcalidrawElement, + Ordered, } from "../element/types"; -import { getNonDeletedElements, isNonDeletedElement } from "../element"; +import { isNonDeletedElement } from "../element"; import { LinearElementEditor } from "../element/linearElementEditor"; import { isFrameLikeElement } from "../element/typeChecks"; import { getSelectedElements } from "./selection"; import { AppState } from "../types"; import { Assert, SameType } from "../utility-types"; import { randomInteger } from "../random"; +import { + syncInvalidIndices, + syncMovedIndices, + validateFractionalIndices, +} from "../fractionalIndex"; +import { arrayToMap } from "../utils"; +import { toBrandedType } from "../utils"; +import { ENV } from "../constants"; type ElementIdKey = InstanceType["elementId"]; type ElementKey = ExcalidrawElement | ElementIdKey; @@ -20,6 +33,23 @@ type SceneStateCallbackRemover = () => void; type SelectionHash = string & { __brand: "selectionHash" }; +const getNonDeletedElements = ( + allElements: readonly T[], +) => { + const elementsMap = new Map() as NonDeletedSceneElementsMap; + const elements: T[] = []; + for (const element of allElements) { + if (!element.isDeleted) { + elements.push(element as NonDeleted); + elementsMap.set( + element.id, + element as Ordered, + ); + } + } + return { elementsMap, elements }; +}; + const hashSelectionOpts = ( opts: Parameters["getSelectedElements"]>[0], ) => { @@ -62,29 +92,16 @@ class Scene { private static sceneMapByElement = new WeakMap(); private static sceneMapById = new Map(); - static mapElementToScene( - elementKey: ElementKey, - scene: Scene, - /** - * needed because of frame exporting hack. - * elementId:Scene mapping will be removed completely, soon. - */ - mapElementIds = true, - ) { + static mapElementToScene(elementKey: ElementKey, scene: Scene) { if (isIdKey(elementKey)) { - if (!mapElementIds) { - return; - } // for cases where we don't have access to the element object // (e.g. restore serialized appState with id references) this.sceneMapById.set(elementKey, scene); } else { this.sceneMapByElement.set(elementKey, scene); - if (!mapElementIds) { - // if mapping element objects, also cache the id string when later - // looking up by id alone - this.sceneMapById.set(elementKey.id, scene); - } + // if mapping element objects, also cache the id string when later + // looking up by id alone + this.sceneMapById.set(elementKey.id, scene); } } @@ -101,12 +118,17 @@ class Scene { private callbacks: Set = new Set(); - private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; - private elements: readonly ExcalidrawElement[] = []; + private nonDeletedElements: readonly Ordered[] = + []; + private nonDeletedElementsMap = toBrandedType( + new Map(), + ); + // ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so + private elements: readonly OrderedExcalidrawElement[] = []; private nonDeletedFramesLikes: readonly NonDeleted[] = []; private frames: readonly ExcalidrawFrameLikeElement[] = []; - private elementsMap = new Map(); + private elementsMap = toBrandedType(new Map()); private selectedElementsCache: { selectedElementIds: AppState["selectedElementIds"] | null; elements: readonly NonDeletedExcalidrawElement[] | null; @@ -118,11 +140,19 @@ class Scene { }; private versionNonce: number | undefined; + getElementsMapIncludingDeleted() { + return this.elementsMap; + } + + getNonDeletedElementsMap() { + return this.nonDeletedElementsMap; + } + getElementsIncludingDeleted() { return this.elements; } - getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] { + getNonDeletedElements() { return this.nonDeletedElements; } @@ -138,7 +168,7 @@ class Scene { * scene state. This in effect will likely result in cache-miss, and * the cache won't be updated in this case. */ - elements?: readonly ExcalidrawElement[]; + elements?: ElementsMapOrArray; // selection-related options includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; @@ -227,23 +257,34 @@ class Scene { return didChange; } - replaceAllElements( - nextElements: readonly ExcalidrawElement[], - mapElementIds = true, - ) { - this.elements = nextElements; + replaceAllElements(nextElements: ElementsMapOrArray) { + const _nextElements = + // ts doesn't like `Array.isArray` of `instanceof Map` + nextElements instanceof Array + ? nextElements + : Array.from(nextElements.values()); const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + // throw on invalid indices in test / dev to potentially detect cases were we forgot to sync moved elements + validateFractionalIndices(_nextElements.map((x) => x.index)); + } + + this.elements = syncInvalidIndices(_nextElements); this.elementsMap.clear(); - nextElements.forEach((element) => { + this.elements.forEach((element) => { if (isFrameLikeElement(element)) { nextFrameLikes.push(element); } this.elementsMap.set(element.id, element); Scene.mapElementToScene(element, this); }); - this.nonDeletedElements = getNonDeletedElements(this.elements); + const nonDeletedElements = getNonDeletedElements(this.elements); + this.nonDeletedElements = nonDeletedElements.elements; + this.nonDeletedElementsMap = nonDeletedElements.elementsMap; + this.frames = nextFrameLikes; - this.nonDeletedFramesLikes = getNonDeletedElements(this.frames); + this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; this.informMutation(); } @@ -272,8 +313,8 @@ class Scene { } destroy() { - this.nonDeletedElements = []; this.elements = []; + this.nonDeletedElements = []; this.nonDeletedFramesLikes = []; this.frames = []; this.elementsMap.clear(); @@ -298,11 +339,15 @@ class Scene { "insertElementAtIndex can only be called with index >= 0", ); } + const nextElements = [ ...this.elements.slice(0, index), element, ...this.elements.slice(index), ]; + + syncMovedIndices(nextElements, arrayToMap([element])); + this.replaceAllElements(nextElements); } @@ -312,26 +357,53 @@ class Scene { "insertElementAtIndex can only be called with index >= 0", ); } + const nextElements = [ ...this.elements.slice(0, index), ...elements, ...this.elements.slice(index), ]; + syncMovedIndices(nextElements, arrayToMap(elements)); + this.replaceAllElements(nextElements); } - addNewElement = (element: ExcalidrawElement) => { - if (element.frameId) { - this.insertElementAtIndex(element, this.getElementIndex(element.frameId)); - } else { - this.replaceAllElements([...this.elements, element]); - } + insertElement = (element: ExcalidrawElement) => { + const index = element.frameId + ? this.getElementIndex(element.frameId) + : this.elements.length; + + this.insertElementAtIndex(element, index); + }; + + insertElements = (elements: ExcalidrawElement[]) => { + const index = elements[0].frameId + ? this.getElementIndex(elements[0].frameId) + : this.elements.length; + + this.insertElementsAtIndex(elements, index); }; getElementIndex(elementId: string) { return this.elements.findIndex((element) => element.id === elementId); } + + getContainerElement = ( + element: + | (ExcalidrawElement & { + containerId: ExcalidrawElement["id"] | null; + }) + | null, + ) => { + if (!element) { + return null; + } + if (element.containerId) { + return this.getElement(element.containerId) || null; + } + return null; + }; } export default Scene; diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 190f7562f..1d43aef71 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -21,6 +21,7 @@ import { isLinearElement, } from "../element/typeChecks"; import { canChangeRoundness } from "./comparisons"; +import { EmbedsValidationStatus } from "../types"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -118,10 +119,13 @@ export const generateRoughOptions = ( const modifyIframeLikeForRoughOptions = ( element: NonDeletedExcalidrawElement, isExporting: boolean, + embedsValidationStatus: EmbedsValidationStatus | null, ) => { if ( isIframeLikeElement(element) && - (isExporting || (isEmbeddableElement(element) && !element.validated)) && + (isExporting || + (isEmbeddableElement(element) && + embedsValidationStatus?.get(element.id) !== true)) && isTransparent(element.backgroundColor) && isTransparent(element.strokeColor) ) { @@ -278,7 +282,12 @@ export const _generateElementShape = ( { isExporting, canvasBackgroundColor, - }: { isExporting: boolean; canvasBackgroundColor: string }, + embedsValidationStatus, + }: { + isExporting: boolean; + canvasBackgroundColor: string; + embedsValidationStatus: EmbedsValidationStatus | null; + }, ): Drawable | Drawable[] | null => { switch (element.type) { case "rectangle": @@ -299,7 +308,11 @@ export const _generateElementShape = ( h - r } L 0 ${r} Q 0 0, ${r} 0`, generateRoughOptions( - modifyIframeLikeForRoughOptions(element, isExporting), + modifyIframeLikeForRoughOptions( + element, + isExporting, + embedsValidationStatus, + ), true, ), ); @@ -310,7 +323,11 @@ export const _generateElementShape = ( element.width, element.height, generateRoughOptions( - modifyIframeLikeForRoughOptions(element, isExporting), + modifyIframeLikeForRoughOptions( + element, + isExporting, + embedsValidationStatus, + ), false, ), ); diff --git a/packages/excalidraw/scene/ShapeCache.ts b/packages/excalidraw/scene/ShapeCache.ts index e5a08c1f2..3bca88e85 100644 --- a/packages/excalidraw/scene/ShapeCache.ts +++ b/packages/excalidraw/scene/ShapeCache.ts @@ -8,7 +8,7 @@ import { elementWithCanvasCache } from "../renderer/renderElement"; import { _generateElementShape } from "./Shape"; import { ElementShape, ElementShapes } from "./types"; import { COLOR_PALETTE } from "../colors"; -import { AppState } from "../types"; +import { AppState, EmbedsValidationStatus } from "../types"; export class ShapeCache { private static rg = new RoughGenerator(); @@ -51,6 +51,7 @@ export class ShapeCache { renderConfig: { isExporting: boolean; canvasBackgroundColor: AppState["viewBackgroundColor"]; + embedsValidationStatus: EmbedsValidationStatus; } | null, ) => { // when exporting, always regenerated to guarantee the latest shape @@ -72,6 +73,7 @@ export class ShapeCache { renderConfig || { isExporting: false, canvasBackgroundColor: COLOR_PALETTE.white, + embedsValidationStatus: null, }, ) as T["type"] extends keyof ElementShapes ? ElementShapes[T["type"]] diff --git a/packages/excalidraw/scene/comparisons.ts b/packages/excalidraw/scene/comparisons.ts index 551aa2e6e..cb14d5810 100644 --- a/packages/excalidraw/scene/comparisons.ts +++ b/packages/excalidraw/scene/comparisons.ts @@ -42,7 +42,8 @@ export const canChangeRoundness = (type: ElementOrToolType) => type === "embeddable" || type === "arrow" || type === "line" || - type === "diamond"; + type === "diamond" || + type === "image"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index b4702af12..446578c22 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -4,20 +4,22 @@ import { ExcalidrawFrameLikeElement, ExcalidrawTextElement, NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "../element/types"; import { Bounds, getCommonBounds, getElementAbsoluteCoords, } from "../element/bounds"; -import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; -import { cloneJSON, distance, getFontString } from "../utils"; +import { renderSceneToSvg } from "../renderer/staticSvgScene"; +import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, FONT_FAMILY, FRAME_STYLE, SVG_NS, + THEME, THEME_FILTER, } from "../constants"; import { getDefaultAppState } from "../appState"; @@ -26,8 +28,8 @@ import { getInitializedImageElements, updateImageCache, } from "../element/image"; -import { elementsOverlappingBBox } from "../../withinBounds"; import { + getElementsOverlappingFrame, getFrameLikeElements, getFrameLikeTitle, getRootElements, @@ -35,34 +37,13 @@ import { import { newTextElement } from "../element"; import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; -import Scene from "./Scene"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; +import { RenderableElementsMap } from "./types"; +import { syncInvalidIndices } from "../fractionalIndex"; +import { renderStaticScene } from "../renderer/staticScene"; const SVG_EXPORT_TAG = ``; -// getContainerElement and getBoundTextElement and potentially other helpers -// depend on `Scene` which will not be available when these pure utils are -// called outside initialized Excalidraw editor instance or even if called -// from inside Excalidraw if the elements were never cached by Scene (e.g. -// for library elements). -// -// As such, before passing the elements down, we need to initialize a custom -// Scene instance and assign them to it. -// -// FIXME This is a super hacky workaround and we'll need to rewrite this soon. -const __createSceneForElementsHack__ = ( - elements: readonly ExcalidrawElement[], -) => { - const scene = new Scene(); - // we can't duplicate elements to regenerate ids because we need the - // orig ids when embedding. So we do another hack of not mapping element - // ids to Scene instances so that we don't override the editor elements - // mapping. - // We still need to clone the objects themselves to regen references. - scene.replaceAllElements(cloneJSON(elements), false); - return scene; -}; - const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { if (element.width <= maxWidth) { return element; @@ -168,11 +149,7 @@ const prepareElementsForRender = ({ let nextElements: readonly ExcalidrawElement[]; if (exportingFrame) { - nextElements = elementsOverlappingBBox({ - elements, - bounds: exportingFrame, - type: "overlap", - }); + nextElements = getElementsOverlappingFrame(elements, exportingFrame); } else if (frameRendering.enabled && frameRendering.name) { nextElements = addFrameLabelsAsTextElements(elements, { exportWithDarkMode, @@ -209,9 +186,6 @@ export const exportToCanvas = async ( return { canvas, scale: appState.exportScale }; }, ) => { - const tempScene = __createSceneForElementsHack__(elements); - elements = tempScene.getNonDeletedElements(); - const frameRendering = getFrameRenderingConfig( exportingFrame ?? null, appState.frameRendering ?? null, @@ -248,7 +222,12 @@ export const exportToCanvas = async ( renderStaticScene({ canvas, rc: rough.canvas(canvas), - elements: elementsForRender, + elementsMap: toBrandedType( + arrayToMap(elementsForRender), + ), + allElementsMap: toBrandedType( + arrayToMap(syncInvalidIndices(elements)), + ), visibleElements: elementsForRender, scale, appState: { @@ -259,18 +238,19 @@ export const exportToCanvas = async ( scrollY: -minY + exportPadding, zoom: defaultAppState.zoom, shouldCacheIgnoreZoom: false, - theme: appState.exportWithDarkMode ? "dark" : "light", + theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, }, renderConfig: { canvasBackgroundColor: viewBackgroundColor, imageCache, renderGrid: false, isExporting: true, + // empty disables embeddable rendering + embedsValidationStatus: new Map(), + elementsPendingErasure: new Set(), }, }); - tempScene.destroy(); - return canvas; }; @@ -287,13 +267,13 @@ export const exportToSvg = async ( }, files: BinaryFiles | null, opts?: { + /** + * if true, all embeddables passed in will be rendered when possible. + */ renderEmbeddables?: boolean; exportingFrame?: ExcalidrawFrameLikeElement | null; }, ): Promise => { - const tempScene = __createSceneForElementsHack__(elements); - elements = tempScene.getNonDeletedElements(); - const frameRendering = getFrameRenderingConfig( opts?.exportingFrame ?? null, appState.frameRendering ?? null, @@ -327,7 +307,7 @@ export const exportToSvg = async ( if (exportEmbedScene) { try { metadata = await ( - await import(/* webpackChunkName: "image" */ "../data/image") + await import("../data/image") ).encodeSvgMetadata({ // when embedding scene, we want to embed the origionally supplied // elements which don't contain the temp frame labels. @@ -377,8 +357,9 @@ export const exportToSvg = async ( const frameElements = getFrameLikeElements(elements); let exportingFrameClipPath = ""; + const elementsMap = arrayToMap(elements); for (const frame of frameElements) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const cx = (x2 - x1) / 2 - (frame.x - x1); const cy = (y2 - y1) / 2 - (frame.y - y1); @@ -427,17 +408,32 @@ export const exportToSvg = async ( } const rsvg = rough.svg(svgRoot); - renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, { - offsetX, - offsetY, - isExporting: true, - exportWithDarkMode, - renderEmbeddables: opts?.renderEmbeddables ?? false, - frameRendering, - canvasBackgroundColor: viewBackgroundColor, - }); - tempScene.destroy(); + const renderEmbeddables = opts?.renderEmbeddables ?? false; + + renderSceneToSvg( + elementsForRender, + toBrandedType(arrayToMap(elementsForRender)), + rsvg, + svgRoot, + files || {}, + { + offsetX, + offsetY, + isExporting: true, + exportWithDarkMode, + renderEmbeddables, + frameRendering, + canvasBackgroundColor: viewBackgroundColor, + embedsValidationStatus: renderEmbeddables + ? new Map( + elementsForRender + .filter((element) => isFrameLikeElement(element)) + .map((element) => [element.id, true]), + ) + : new Map(), + }, + ); return svgRoot; }; diff --git a/packages/excalidraw/scene/index.ts b/packages/excalidraw/scene/index.ts index 5a7b9028a..33399d79e 100644 --- a/packages/excalidraw/scene/index.ts +++ b/packages/excalidraw/scene/index.ts @@ -1,4 +1,3 @@ -export { isOverScrollBars } from "./scrollbars"; export { isSomeElementSelected, getElementsWithinSelection, diff --git a/packages/excalidraw/scene/scrollbars.ts b/packages/excalidraw/scene/scrollbars.ts index 1d93f688f..0e0e6d0ab 100644 --- a/packages/excalidraw/scene/scrollbars.ts +++ b/packages/excalidraw/scene/scrollbars.ts @@ -1,9 +1,9 @@ -import { ExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element"; import { InteractiveCanvasAppState } from "../types"; import { ScrollBars } from "./types"; import { getGlobalCSSVariable } from "../utils"; import { getLanguage } from "../i18n"; +import { ExcalidrawElement } from "../element/types"; export const SCROLLBAR_MARGIN = 4; export const SCROLLBAR_WIDTH = 6; @@ -15,7 +15,7 @@ export const getScrollBars = ( viewportHeight: number, appState: InteractiveCanvasAppState, ): ScrollBars => { - if (elements.length === 0) { + if (!elements.length) { return { horizontal: null, vertical: null, diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 7a620155f..deec19406 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -1,4 +1,6 @@ import { + ElementsMap, + ElementsMapOrArray, ExcalidrawElement, NonDeletedExcalidrawElement, } from "../element/types"; @@ -43,18 +45,24 @@ export const excludeElementsInFramesFromSelection = < export const getElementsWithinSelection = ( elements: readonly NonDeletedExcalidrawElement[], selection: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, excludeElementsInFrames: boolean = true, ) => { const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(selection); + getElementAbsoluteCoords(selection, elementsMap); let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = - getElementBounds(element); + let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( + element, + elementsMap, + ); - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); if (containingFrame) { - const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame); + const [fx1, fy1, fx2, fy2] = getElementBounds( + containingFrame, + elementsMap, + ); elementX1 = Math.max(fx1, elementX1); elementY1 = Math.max(fy1, elementY1); @@ -78,10 +86,10 @@ export const getElementsWithinSelection = ( : elementsInSelection; elementsInSelection = elementsInSelection.filter((element) => { - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); if (containingFrame) { - return elementOverlapsWithFrame(element, containingFrame); + return elementOverlapsWithFrame(element, containingFrame, elementsMap); } return true; @@ -94,6 +102,7 @@ export const getVisibleAndNonSelectedElements = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const selectedElementsSet = new Set( selectedElements.map((element) => element.id), @@ -104,6 +113,7 @@ export const getVisibleAndNonSelectedElements = ( appState.width, appState.height, appState, + elementsMap, ); return !selectedElementsSet.has(element.id) && isVisible; @@ -166,26 +176,28 @@ export const getCommonAttributeOfSelectedElements = ( }; export const getSelectedElements = ( - elements: readonly NonDeletedExcalidrawElement[], + elements: ElementsMapOrArray, appState: Pick, opts?: { includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; }, ) => { - const selectedElements = elements.filter((element) => { + const selectedElements: ExcalidrawElement[] = []; + for (const element of elements.values()) { if (appState.selectedElementIds[element.id]) { - return element; + selectedElements.push(element); + continue; } if ( opts?.includeBoundTextElement && isBoundToContainer(element) && appState.selectedElementIds[element?.containerId] ) { - return element; + selectedElements.push(element); + continue; } - return null; - }); + } if (opts?.includeElementsInFrames) { const elementsToInclude: ExcalidrawElement[] = []; @@ -205,7 +217,7 @@ export const getSelectedElements = ( }; export const getTargetElements = ( - elements: readonly NonDeletedExcalidrawElement[], + elements: ElementsMapOrArray, appState: Pick, ) => appState.editingElement diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index b4320866c..63a49fec5 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -1,15 +1,26 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { + ExcalidrawElement, ExcalidrawTextElement, + NonDeletedElementsMap, NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "../element/types"; import { AppClassProperties, AppState, + EmbedsValidationStatus, + ElementsPendingErasure, InteractiveCanvasAppState, StaticCanvasAppState, + SocketId, + UserIdleState, } from "../types"; +import { MakeBrand } from "../utility-types"; + +export type RenderableElementsMap = NonDeletedElementsMap & + MakeBrand<"RenderableElementsMap">; export type StaticCanvasRenderConfig = { canvasBackgroundColor: AppState["viewBackgroundColor"]; @@ -20,6 +31,8 @@ export type StaticCanvasRenderConfig = { /** when exporting the behavior is slightly different (e.g. we can't use CSS filters), and we disable render optimizations for best output */ isExporting: boolean; + embedsValidationStatus: EmbedsValidationStatus; + elementsPendingErasure: ElementsPendingErasure; }; export type SVGRenderConfig = { @@ -30,16 +43,17 @@ export type SVGRenderConfig = { renderEmbeddables: boolean; frameRendering: AppState["frameRendering"]; canvasBackgroundColor: AppState["viewBackgroundColor"]; + embedsValidationStatus: EmbedsValidationStatus; }; export type InteractiveCanvasRenderConfig = { // collab-related state // --------------------------------------------------------------------------- - remoteSelectedElementIds: { [elementId: string]: string[] }; - remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; - remotePointerUserStates: { [id: string]: string }; - remotePointerUsernames: { [id: string]: string }; - remotePointerButton?: { [id: string]: string | undefined }; + remoteSelectedElementIds: Map; + remotePointerViewportCoords: Map; + remotePointerUserStates: Map; + remotePointerUsernames: Map; + remotePointerButton: Map; selectionColor?: string; // extra options passed to the renderer // --------------------------------------------------------------------------- @@ -48,14 +62,15 @@ export type InteractiveCanvasRenderConfig = { export type RenderInteractiveSceneCallback = { atLeastOneVisibleElement: boolean; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; scrollBars?: ScrollBars; }; export type StaticSceneRenderConfig = { canvas: HTMLCanvasElement; rc: RoughCanvas; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; + allElementsMap: NonDeletedSceneElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; scale: number; appState: StaticCanvasAppState; @@ -64,7 +79,7 @@ export type StaticSceneRenderConfig = { export type InteractiveSceneRenderConfig = { canvas: HTMLCanvasElement | null; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; scale: number; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index e7ff9b787..bc83d0057 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -8,13 +8,17 @@ import { import { MaybeTransformHandleType } from "./element/transformHandles"; import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks"; import { + ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; import { getMaximumGroups } from "./groups"; import { KEYS } from "./keys"; import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; -import { getVisibleAndNonSelectedElements } from "./scene/selection"; +import { + getSelectedElements, + getVisibleAndNonSelectedElements, +} from "./scene/selection"; import { AppState, KeyboardModifiersObject, Point } from "./types"; const SNAP_DISTANCE = 8; @@ -166,6 +170,7 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { export const getElementsCorners = ( elements: ExcalidrawElement[], + elementsMap: ElementsMap, { omitCenter, boundingBoxCorners, @@ -184,7 +189,10 @@ export const getElementsCorners = ( if (elements.length === 1) { const element = elements[0]; - let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); if (dragOffset) { x1 += dragOffset.x; @@ -261,6 +269,7 @@ const getReferenceElements = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const selectedFrames = selectedElements .filter((element) => isFrameLikeElement(element)) @@ -270,6 +279,7 @@ const getReferenceElements = ( elements, selectedElements, appState, + elementsMap, ).filter( (element) => !(element.frameId && selectedFrames.includes(element.frameId)), ); @@ -279,14 +289,16 @@ export const getVisibleGaps = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: ExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const referenceElements: ExcalidrawElement[] = getReferenceElements( elements, selectedElements, appState, + elementsMap, ); - const referenceBounds = getMaximumGroups(referenceElements) + const referenceBounds = getMaximumGroups(referenceElements, elementsMap) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), @@ -565,19 +577,20 @@ export const getReferenceSnapPoints = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: ExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const referenceElements = getReferenceElements( elements, selectedElements, appState, + elementsMap, ); - - return getMaximumGroups(referenceElements) + return getMaximumGroups(referenceElements, elementsMap) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), ) - .flatMap((elementGroup) => getElementsCorners(elementGroup)); + .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); }; const getPointSnaps = ( @@ -637,11 +650,13 @@ const getPointSnaps = ( }; export const snapDraggedElements = ( - selectedElements: ExcalidrawElement[], + elements: ExcalidrawElement[], dragOffset: Vector2D, appState: AppState, event: KeyboardModifiersObject, + elementsMap: ElementsMap, ) => { + const selectedElements = getSelectedElements(elements, appState); if ( !isSnappingEnabled({ appState, event, selectedElements }) || selectedElements.length === 0 @@ -654,7 +669,6 @@ export const snapDraggedElements = ( snapLines: [], }; } - dragOffset.x = round(dragOffset.x); dragOffset.y = round(dragOffset.y); const nearestSnapsX: Snaps = []; @@ -665,7 +679,7 @@ export const snapDraggedElements = ( y: snapDistance, }; - const selectionPoints = getElementsCorners(selectedElements, { + const selectionPoints = getElementsCorners(selectedElements, elementsMap, { dragOffset, }); @@ -715,7 +729,7 @@ export const snapDraggedElements = ( getPointSnaps( selectedElements, - getElementsCorners(selectedElements, { + getElementsCorners(selectedElements, elementsMap, { dragOffset: newDragOffset, }), appState, @@ -1200,6 +1214,7 @@ export const snapNewElement = ( event: KeyboardModifiersObject, origin: Vector2D, dragOffset: Vector2D, + elementsMap: ElementsMap, ) => { if ( !isSnappingEnabled({ event, selectedElements: [draggingElement], appState }) @@ -1244,7 +1259,7 @@ export const snapNewElement = ( nearestSnapsX.length = 0; nearestSnapsY.length = 0; - const corners = getElementsCorners([draggingElement], { + const corners = getElementsCorners([draggingElement], elementsMap, { boundingBoxCorners: true, omitCenter: true, }); @@ -1272,6 +1287,7 @@ export const getSnapLinesAtPointer = ( appState: AppState, pointer: Vector2D, event: KeyboardModifiersObject, + elementsMap: ElementsMap, ) => { if (!isSnappingEnabled({ event, selectedElements: [], appState })) { return { @@ -1284,6 +1300,7 @@ export const getSnapLinesAtPointer = ( elements, [], appState, + elementsMap, ); const snapDistance = getSnapDistance(appState.zoom.value); @@ -1297,7 +1314,7 @@ export const getSnapLinesAtPointer = ( const verticalSnapLines: PointerSnapLine[] = []; for (const referenceElement of referenceElements) { - const corners = getElementsCorners([referenceElement]); + const corners = getElementsCorners([referenceElement], elementsMap); for (const corner of corners) { const offsetX = corner[0] - pointer.x; diff --git a/packages/excalidraw/tests/App.test.tsx b/packages/excalidraw/tests/App.test.tsx index 316d274ef..9fb055453 100644 --- a/packages/excalidraw/tests/App.test.tsx +++ b/packages/excalidraw/tests/App.test.tsx @@ -1,12 +1,12 @@ import ReactDOM from "react-dom"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { render, queryByTestId } from "../tests/test-utils"; import { Excalidraw } from "../index"; import { vi } from "vitest"; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); describe("Test ", () => { beforeEach(async () => { diff --git a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx index 21946bab1..1e782cfb2 100644 --- a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx +++ b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, waitFor } from "./test-utils"; +import { act, render, waitFor } from "./test-utils"; import { Excalidraw } from "../index"; import React from "react"; import { expect, vi } from "vitest"; @@ -115,19 +115,6 @@ describe("Test ", () => { expect(dialog.outerHTML).toMatchSnapshot(); }); - it("should close the popup and set the tool to selection when close button clicked", () => { - const dialog = document.querySelector(".ttd-dialog")!; - const closeBtn = dialog.querySelector(".Dialog__close")!; - fireEvent.click(closeBtn); - expect(document.querySelector(".ttd-dialog")).toBe(null); - expect(window.h.state.activeTool).toStrictEqual({ - customType: null, - lastActiveTool: null, - locked: false, - type: "selection", - }); - }); - it("should show error in preview when mermaid library throws error", async () => { const dialog = document.querySelector(".ttd-dialog")!; diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 0e25dc33d..1850f074f 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Test > should open mermaid popup when active tool is mermaid 1`] = ` -"