Compare commits

...

60 Commits

Author SHA1 Message Date
d2038b7c5a Merge branch 'master' into mrazator/delta-based-sync 2025-04-23 14:35:15 +02:00
1913599594 refactor: remove dependency on the (static) Scene (#9389) 2025-04-23 13:45:08 +02:00
debf2ad608 docs: Fix missing verb in Footer component documentation (#9393) 2025-04-20 12:35:38 +02:00
8fb2f70414 fix: scrollbar rendering and improve dragging (#9417)
* fix: scrollbar rendering and improve dragging

* tweak offsets
2025-04-20 12:28:41 +02:00
5fc13e4309 feat: add props.renderScrollbars (#9399)
* Expose renderScrollbars to AppState

* fix: scrollbar rendering should use al renderable elements

* remove `appState.renderScrollbars`

* clean unused

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-19 21:50:44 +00:00
b5d60973b7 fix: duplication tests pointer state leaking between tests (#9414)
* fix: duplication tests pointer state leaking between tests

* fix snapshots
2025-04-18 11:11:12 +02:00
a5d6939826 fix: keep orig elem in place on alt-duplication (#9403)
* fix: keep orig elem in place on alt-duplication

* clarify comment

* fix: incorrect selection on duplicating labeled containers

* fix: duplicating within group outside frame should remove from group
2025-04-17 16:08:07 +02:00
0cf36d6b30 fix: erasing locked elements (#9400)
* fix: erasing locked elements

* signature tweaks
2025-04-16 10:28:56 +02:00
58f7d33d80 perf: make eraser great again (#9352)
* perf: make eraser great again

* lint

* refactor and improve perf

* lint
2025-04-15 16:58:45 +02:00
6fe7de8020 fix: Add DOCTYPE and XML preamble in exported SVG documents (#9386)
* Add DOCTYPE and XML preamble in exported SVG documents

* Update packages/excalidraw/data/index.ts

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-04-14 21:25:18 +02:00
01304aac49 feat: Keep text label horizontal (#9364)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-13 21:21:49 +02:00
dff69e9191 chore: Element center point util (#9298) 2025-04-09 17:04:51 +02:00
6fc85022ae fix: lasso selection issues (#9353)
* revert stroke slicing hack for knot

* fix incorrect closing of path

* nonzero enclosure

* lint
2025-04-08 00:50:52 +10:00
e48b63a0ae fix: Rounded diamond edge elbow arrow U route (#9349) 2025-04-07 10:43:07 +02:00
c2caf78e95 fix: deselected hit element being duplicated + incorrect re-seeding (#9333)
* fix: deselected hit element being duplicated + incorrect re-seeding

* snapshots

* Fix alt-drag binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Add alt-drag bound arrow test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 10:41:31 +02:00
ce267aa0d3 feat: lasso selection (#9169)
* lasso without 'real' shape detection

* select a single linear el

* improve ux

* feed segments to worker

* simplify path threshold adaptive to zoom

* add a tiny threshold for checks

* refactor code

* lasso tests

* fix: ts

* do not capture lasso tool

* try worker-loader in next config

* update config

* refactor

* lint

* feat: show active tool when using "more tools"

* keep lasso if selected from toolbar

* fix incorrect checks for resetting to selection

* shift for additive selection

* bound text related fixes

* lint

* keep alt toggled lasso selection if shift pressed

* fix regression

* fix 'dead' lassos

* lint

* use workerpool and polyfill

* fix worker bundled with window related code

* refactor

* add file extension for worker constructor error

* another attempt at constructor error

* attempt at build issue

* attempt with dynamic import

* test not importing from math

* narrow down imports

* Reusing existing workers infrastructure (fallback to the main thread, type-safety)

* Points on curve inside the shared chunk

* Give up on experimental code splitting

* Remove potentially unnecessary optimisation

* Removing workers as the complexit is much worse, while perf. does not seem to be much better

* fix selecting text containers and containing frames together

* render fill directly from animated trail

* do not re-render static when setting selected element ids in lasso

* remove unnecessary property

* tweak trail animation

* slice points to remove notch

* always start alt-lasso from initial point

* revert build & worker changes (unused)

* remove `lasso` from `hasStrokeColor`

* label change

* remove unused props

* remove unsafe optimization

* snaps

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2025-04-07 16:44:25 +10:00
6e47fadb59 feat: add container to multiple text elements (#9348) 2025-04-07 00:57:27 +02:00
b3d5ba0567 fix: Linear element is not normalized (#9347)
* Fix #9292
2025-04-06 13:41:11 +02:00
c79e892e55 chore: bump @radix-ui/react-tabs version to 1.1.3 (#9329)
* chore: bump @radix-ui/react-tabs version to 1.1.3

bumped the version to latest stable that includes
react ^19 as peerDepenecy.
This fixes peerDependency issues, as reported in #9253

* redeploy

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-02 16:23:15 +02:00
57a9e301d4 feat: tweak color swatch, and button bgs (#9330)
* feat: tweak color swatch, and button bgs

* snapshots
2025-04-02 14:36:13 +02:00
7c58477382 feat: tweak properties panel styling (#9322) 2025-03-30 19:20:13 +02:00
83fac6d0db feat: tweak stats panel input styles (#9321) 2025-03-30 19:00:31 +02:00
f2e8404c7b feat: allow to disable preventUnload in dev (#9319)
* feat: allow to disable preventUnload in dev

* add template
2025-03-29 19:42:33 +01:00
d797c2e210 fix: strip legacy attrs on element restore (#9318) 2025-03-29 19:31:16 +01:00
de81ba25fd Merge branch 'master' into mrazator/delta-based-sync 2025-03-28 13:23:34 +01:00
0cd5a259ae fix: incorrect type imports (#9308) 2025-03-27 12:00:12 +01:00
432a46ef9e refactor: separate elements logic into a standalone package (#9285) 2025-03-26 15:24:59 +01:00
a18f059188 fix: Reduce allocations in collision detection (#9299)
Reduce allocations
2025-03-26 15:10:43 +01:00
ab89d4c16f feat: add keyboard shortcut to save file in text (#9295)
Co-authored-by: Aviral Sharma <aviralsharma954@gmail.com>
2025-03-25 22:18:55 +01:00
6c3a434f2a docs: Fix table rendering and broken links in Props page (#9293)
* Fix table rendering and broken links

---------

Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2025-03-25 14:32:15 +01:00
e1bb59fb8f chore: Use isDevEnv() and isTestEnv() (#9264)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-03-24 19:44:00 +01:00
77aca48c84 fix: Refactor and merge duplication and binding (#9246) 2025-03-23 18:39:33 +01:00
58990b41ae fix: 'Rotate' spell error (#9288) 2025-03-22 09:06:23 +00:00
99d8bff175 fix: elements offset incorrectly when action-duplicated during drag (#9275)
* fix: elements offset incorrectly when action-duplicated during drag

* prevent duplicate action during drag altogether
2025-03-15 20:05:42 +01:00
30983d801a fix: Arrow conversion regression (#9241)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-03-15 12:31:25 +01:00
21ffaf4d76 refactor: auto ordered imports (#9163) 2025-03-12 15:23:31 +01:00
82b9a6b464 docs: CHANGELOG typos 🙏 (#9250) 2025-03-11 23:18:15 +01:00
858c65b314 Deltas in a separate package [wip] 2025-02-07 15:33:28 +01:00
f00069be68 Decouple do from package 2025-02-04 13:30:22 +01:00
7b72406824 Server snapshot WIP 2025-01-29 17:55:51 +01:00
49925038fd Switch from sqlite payload strings to buffers, utils refactor, dev logging 2025-01-29 17:44:45 +01:00
05ba0339fe Ditching strings and exchanging buffers 2025-01-29 17:44:45 +01:00
cdd7f6158b Testing concurrent remote updates (wip) 2025-01-29 17:44:45 +01:00
7e0f5b6369 Cache received changes, ignore snapshot cache for durable changes, revert StoreAction, history fix, indices fix 2025-01-29 17:44:45 +01:00
310a9ae4e0 Syncing ephemeral element updates 2025-01-29 17:43:38 +01:00
c57249481e Custom room, various fixes 2025-01-29 17:41:42 +01:00
e72d83541a Don't strip seed 2025-01-29 17:41:42 +01:00
9f8c87ae8c Fix auto-reconnection & slider value sync 2025-01-29 17:41:42 +01:00
f6061f5ec6 Sharding rows due to SQLite limits 2025-01-29 17:41:42 +01:00
12be5d716b Chunking incoming WS messages 2025-01-29 17:41:42 +01:00
1abb901ec2 Various sync & time travel fixes 2025-01-29 17:41:42 +01:00
6a17541713 Auto-reconnecting WS client 2025-01-29 17:41:32 +01:00
040a57f56a Offline support with increments peristed and restored to / from indexedb 2025-01-29 17:41:32 +01:00
15d2942aaa Applying & emitting increments on undo / redo 2025-01-29 17:40:50 +01:00
59a0653fd4 POC versioning slider 2025-01-29 17:40:38 +01:00
725c25c966 Include runtime types, otherwise ts goes crazy 2025-01-29 17:40:08 +01:00
d2fed34a30 Deployed sync server 2025-01-29 17:38:03 +01:00
f12ed8e0b2 WIP sync client 2025-01-29 17:38:03 +01:00
508cfbc843 Temporarily move sync into package 2025-01-29 17:38:03 +01:00
245d681b7d Expose store, a bit 2025-01-29 17:37:55 +01:00
520 changed files with 22381 additions and 9282 deletions

View File

@ -48,3 +48,6 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
# set to true in .env.development.local to disable the prevent unload dialog
VITE_APP_DISABLE_PREVENT_UNLOAD=

View File

@ -1,6 +1,21 @@
{
"extends": ["@excalidraw/eslint-config", "react-app"],
"rules": {
"import/order": [
"warn",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"pathGroups": [
{
"pattern": "@excalidraw/**",
"group": "external",
"position": "after"
}
],
"newlines-between": "always-and-inside-groups",
"warnOnUnassignedImports": true
}
],
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": [

View File

@ -2,7 +2,7 @@
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node.
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node.
**Usage**
@ -25,7 +25,7 @@ function App() {
}
```
This will only for `Desktop` devices.
This will only work for `Desktop` devices.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
@ -65,4 +65,4 @@ const App = () => (
// Need to render when code is span across multiple components
// in Live Code blocks editor
render(<App />);
```
```

View File

@ -3,7 +3,7 @@
All `props` are _optional_.
| Name | Type | Default | Description |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode |
@ -13,7 +13,7 @@ All `props` are _optional_.
| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`generateLinkForSelection`](#generatelinkforselection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner |
@ -29,8 +29,9 @@ All `props` are _optional_.
| [`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 |
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
### Storing custom data on Excalidraw elements

View File

@ -1,5 +1,6 @@
import React from "react";
import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";
const FeatureList = [

View File

@ -1,5 +1,6 @@
import React from "react";
import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css";
type FeatureItem = {

View File

@ -1,10 +1,11 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage";
import Layout from "@theme/Layout";
import clsx from "clsx";
import React from "react";
import styles from "./index.module.css";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();

View File

@ -1,6 +1,6 @@
// Import the original mapper
import MDXComponents from "@theme-original/MDXComponents";
import Highlight from "@site/src/components/Highlight";
import MDXComponents from "@theme-original/MDXComponents";
export default {
// Re-use the default mapping

View File

@ -1,5 +1,6 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically

View File

@ -1,10 +1,11 @@
"use client";
import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../with-script-in-browser/components/ExampleApp";
import "@excalidraw/excalidraw/index.css";
import App from "../../with-script-in-browser/components/ExampleApp";
const ExcalidrawWrapper: React.FC = () => {
return (
<>

View File

@ -1,4 +1,5 @@
import dynamic from "next/dynamic";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically

View File

@ -1,4 +1,5 @@
import React from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";

View File

@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import React, {
useEffect,
useState,
@ -6,13 +7,24 @@ import React, {
Children,
cloneElement,
} from "react";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import initialData from "../initialData";
import {
resolvablePromise,
distance2d,
@ -23,25 +35,12 @@ import {
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
import initialData from "../initialData";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import "./ExampleApp.scss";
import type { ResolvablePromise } from "../utils";
type Comment = {
x: number;
y: number;
@ -105,6 +104,7 @@ export default function ExampleApp({
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
@ -193,6 +193,7 @@ export default function ExampleApp({
}) => setPointerData(payload),
viewModeEnabled,
zenModeEnabled,
renderScrollbars,
gridModeEnabled,
theme,
name: "Custom name of drawing",
@ -711,6 +712,14 @@ export default function ExampleApp({
/>
Grid mode
</label>
<label>
<input
type="checkbox"
checked={renderScrollbars}
onChange={() => setRenderScrollbars(!renderScrollbars)}
/>
Render scrollbars
</label>
<label>
<input
type="checkbox"

View File

@ -1,7 +1,9 @@
import React from "react";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import CustomFooter from "./CustomFooter";
const MobileFooter = ({
excalidrawAPI,

View File

@ -1,4 +1,5 @@
import React, { useState } from "react";
import "./ExampleSidebar.scss";
export default function Sidebar({ children }: { children: React.ReactNode }) {

View File

@ -1,10 +1,11 @@
import App from "./components/ExampleApp";
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@excalidraw/excalidraw/index.css";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import App from "./components/ExampleApp";
declare global {
interface Window {

View File

@ -15,7 +15,8 @@
"scripts": {
"start": "vite",
"build": "vite build",
"build:preview": "yarn build && vite preview --port 5002",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
}
}

View File

@ -1,6 +1,6 @@
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/excalidraw";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;

View File

@ -1,24 +1,7 @@
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "@excalidraw/excalidraw/constants";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import Slider from "rc-slider";
import "rc-slider/assets/index.css";
import {
Excalidraw,
LiveCollaborationTrigger,
@ -26,15 +9,23 @@ import {
CaptureUpdateAction,
reconcileElements,
} from "@excalidraw/excalidraw";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "@excalidraw/excalidraw/types";
import type { ResolvablePromise } from "@excalidraw/excalidraw/utils";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
import Trans from "@excalidraw/excalidraw/components/Trans";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
getFrame,
@ -42,75 +33,15 @@ import {
preventUnload,
resolvablePromise,
isRunningInIframe,
} from "@excalidraw/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
isDevEnv,
assertNever,
} from "@excalidraw/common";
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import "./index.scss";
import type { ResolutionType } from "@excalidraw/excalidraw/utility-types";
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "@excalidraw/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
import {
GithubIcon,
XBrandIcon,
@ -121,6 +52,86 @@ import {
share,
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element/elementLink";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import { StoreDelta, DurableStoreIncrement, EphemeralStoreIncrement, StoreIncrement } from "@excalidraw/excalidraw/store";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
import CustomStats from "./CustomStats";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
syncApiAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
@ -131,7 +142,10 @@ import DebugCanvas, {
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { isElementLink } from "@excalidraw/excalidraw/element/elementLink";
import "./index.scss";
import type { CollabAPI } from "./collab/Collab";
polyfill();
@ -362,11 +376,40 @@ const ExcalidrawWrapper = () => {
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [syncAPI] = useAtom(syncApiAtom);
const [sliderVersion, setSliderVersion] = useState(0);
const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
[],
);
const acknowledgedDeltasRef = useRef<StoreDelta[]>(acknowledgedDeltas);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
useEffect(() => {
acknowledgedDeltasRef.current = acknowledgedDeltas;
}, [acknowledgedDeltas]);
useEffect(() => {
const interval = setInterval(() => {
const deltas = syncAPI?.acknowledgedDeltas ?? [];
// CFDO: buffer local deltas as well, not only acknowledged ones
if (deltas.length > acknowledgedDeltasRef.current.length) {
setAcknowledgedDeltas([...deltas]);
setSliderVersion(deltas.length);
}
}, 1000);
syncAPI?.connect();
return () => {
syncAPI?.disconnect();
clearInterval(interval);
};
}, [syncAPI]);
useHandleLibrary({
excalidrawAPI,
adapter: LibraryIndexedDBAdapter,
@ -377,7 +420,7 @@ const ExcalidrawWrapper = () => {
const [, forceRefresh] = useState(false);
useEffect(() => {
if (import.meta.env.DEV) {
if (isDevEnv()) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
@ -602,7 +645,13 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
)
) {
preventUnload(event);
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
@ -663,6 +712,34 @@ const ExcalidrawWrapper = () => {
}
};
const onIncrement = (
increment: DurableStoreIncrement | EphemeralStoreIncrement,
) => {
try {
if (!syncAPI) {
return;
}
if (StoreIncrement.isDurable(increment)) {
// push only if there are element changes
if (!increment.delta.elements.isEmpty()) {
syncAPI.push(increment.delta);
}
return;
}
if (StoreIncrement.isEphemeral(increment)) {
syncAPI.relay(increment.change);
return;
}
assertNever(increment, `Unknown increment type`);
} catch (e) {
console.error("Error during onIncrement handler", e);
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
@ -785,6 +862,57 @@ const ExcalidrawWrapper = () => {
},
};
const debouncedTimeTravel = debounce(
(value: number, direction: "forward" | "backward") => {
if (!excalidrawAPI) {
return;
}
let nextAppState = excalidrawAPI.getAppState();
// CFDO: retrieve the scene map already
let nextElements = new Map(
excalidrawAPI.getSceneElements().map((x) => [x.id, x]),
) as SceneElementsMap;
let deltas: StoreDelta[] = [];
// CFDO I: going both in collaborative setting means the (acknowledge) deltas need to have applied latest changes
switch (direction) {
case "forward": {
deltas = acknowledgedDeltas.slice(sliderVersion, value) ?? [];
break;
}
case "backward": {
deltas = acknowledgedDeltas
.slice(value)
.reverse()
.map((x) => StoreDelta.inverse(x));
break;
}
default:
assertNever(direction, `Unknown direction: ${direction}`);
}
for (const delta of deltas) {
[nextElements, nextAppState] = excalidrawAPI.store.applyDeltaTo(
delta,
nextElements,
nextAppState,
);
}
excalidrawAPI?.updateScene({
appState: {
...nextAppState,
viewModeEnabled: value !== acknowledgedDeltas.length,
},
elements: Array.from(nextElements.values()),
captureUpdate: CaptureUpdateAction.NEVER,
});
},
0,
);
return (
<div
style={{ height: "100%" }}
@ -792,9 +920,45 @@ const ExcalidrawWrapper = () => {
"is-collaborating": isCollaborating,
})}
>
<Slider
style={{
position: "fixed",
bottom: "25px",
zIndex: 999,
width: "60%",
left: "25%",
}}
step={1}
min={0}
max={acknowledgedDeltas.length}
value={sliderVersion}
onChange={(value) => {
const nextSliderVersion = value as number;
// CFDO: in safari the whole canvas gets selected when dragging
if (nextSliderVersion !== acknowledgedDeltas.length) {
// don't listen to updates in the detached mode
syncAPI?.disconnect();
} else {
// reconnect once we're back to the latest version
syncAPI?.connect();
}
if (nextSliderVersion === sliderVersion) {
return;
}
debouncedTimeTravel(
nextSliderVersion,
nextSliderVersion < sliderVersion ? "backward" : "forward",
);
setSliderVersion(nextSliderVersion);
}}
/>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onIncrement={onIncrement}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@ -873,7 +1037,6 @@ const ExcalidrawWrapper = () => {
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}

View File

@ -1,15 +1,21 @@
import { Stats } from "@excalidraw/excalidraw";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import {
DEFAULT_VERSION,
debounce,
getVersion,
nFormatter,
} from "@excalidraw/common";
import { t } from "@excalidraw/excalidraw/i18n";
import { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "./data/localStorage";
import { DEFAULT_VERSION } from "@excalidraw/excalidraw/constants";
import { t } from "@excalidraw/excalidraw/i18n";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { Stats } from "@excalidraw/excalidraw";
type StorageSizes = { scene: number; total: number };

View File

@ -1,13 +1,15 @@
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
} from "@excalidraw/element/types";
import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";

View File

@ -1,6 +1,8 @@
import React from "react";
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
import React from "react";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {

View File

@ -1,5 +1,5 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "@excalidraw/excalidraw";
import LanguageDetector from "i18next-browser-languagedetector";
export const languageDetector = new LanguageDetector();

View File

@ -1,5 +1,7 @@
import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());

View File

@ -45,6 +45,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
IDB_SYNC: "excalidraw-sync",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",

View File

@ -1,21 +1,3 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import {
CaptureUpdateAction,
getSceneVersion,
@ -23,12 +5,54 @@ import {
zoomToFitBounds,
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
assertNever,
isDevEnv,
isTestEnv,
preventUnload,
resolvablePromise,
throttleRAF,
} from "@excalidraw/excalidraw/utils";
} from "@excalidraw/common";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element/mutateElement";
import {
isImageElement,
isInitializedImageElement,
} from "@excalidraw/element/typeChecks";
import { AbortError } from "@excalidraw/excalidraw/errors";
import { t } from "@excalidraw/excalidraw/i18n";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "@excalidraw/excalidraw/data/reconcile";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import {
CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
@ -39,15 +63,17 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
} from "../data";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { LocalData, SyncIndexedDBAdapter } from "../data/LocalData";
import {
isSavedToFirebase,
loadFilesFromFirebase,
@ -59,37 +85,17 @@ import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
} from "../data/localStorage";
import Portal from "./Portal";
import { t } from "@excalidraw/excalidraw/i18n";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
} from "@excalidraw/excalidraw/constants";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { AbortError } from "@excalidraw/excalidraw/errors";
import {
isImageElement,
isInitializedImageElement,
} from "@excalidraw/excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { appJotaiStore, atom } from "../app-jotai";
import type { Mutable, ValueOf } from "@excalidraw/excalidraw/utility-types";
import { getVisibleSceneBounds } from "@excalidraw/excalidraw/element/bounds";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "@excalidraw/excalidraw/data/reconcile";
import { collabErrorIndicatorAtom } from "./CollabError";
import Portal from "./Portal";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
export const syncApiAtom = atom<SyncClient | null>(null);
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false);
@ -236,7 +242,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
appJotaiStore.set(collabAPIAtom, collabAPI);
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
SyncClient.create(this.excalidrawAPI, SyncIndexedDBAdapter).then(
(syncAPI) => {
appJotaiStore.set(syncApiAtom, syncAPI);
},
);
if (isTestEnv() || isDevEnv()) {
window.collab = window.collab || ({} as Window["collab"]);
Object.defineProperties(window, {
collab: {
@ -269,6 +281,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
appJotaiStore.get(syncApiAtom)?.disconnect();
this.onUmmount?.();
}
@ -296,7 +310,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements);
preventUnload(event);
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
}
});
@ -1009,7 +1029,7 @@ declare global {
}
}
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
if (isTestEnv() || isDevEnv()) {
window.collab = window.collab || ({} as Window["collab"]);
}

View File

@ -2,6 +2,7 @@ import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { warning } from "@excalidraw/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss";

View File

@ -1,25 +1,26 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element/mutateElement";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
import type { TCollabClass } from "./Collab";
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import type { Socket } from "socket.io-client";
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
class Portal {
collab: TCollabClass;

View File

@ -1,4 +1,3 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import {
DiagramToCodePlugin,
exportToBlob,
@ -7,7 +6,9 @@ import {
TTDDialog,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/excalidraw/utils";
import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
export const AIComponents = ({
excalidrawAPI,

View File

@ -1,9 +1,11 @@
import React from "react";
import { Footer } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {

View File

@ -1,13 +1,18 @@
import React from "react";
import {
loginIcon,
ExcalLogo,
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { MainMenu } from "@excalidraw/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import React from "react";
import { isDevEnv } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
@ -54,7 +59,7 @@ export const AppMainMenu: React.FC<{
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{import.meta.env.DEV && (
{isDevEnv() && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {

View File

@ -1,9 +1,10 @@
import React from "react";
import { loginIcon } from "@excalidraw/excalidraw/components/icons";
import { POINTER_EVENTS } from "@excalidraw/common";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { WelcomeScreen } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
export const AppWelcomeScreen: React.FC<{
onCollabDialogOpen: () => any;

View File

@ -1,24 +1,28 @@
import { useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/excalidraw/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "@excalidraw/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import type { Curve } from "../../packages/math";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "../../packages/math";
import { isCurve } from "../../packages/math/curve";
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import type { Curve } from "@excalidraw/math";
import { STORAGE_KEYS } from "../app_constants";
const renderLine = (
context: CanvasRenderingContext2D,

View File

@ -1,5 +1,5 @@
import { shield } from "@excalidraw/excalidraw/components/icons";
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { shield } from "@excalidraw/excalidraw/components/icons";
import { useI18n } from "@excalidraw/excalidraw/i18n";
export const EncryptedIcon = () => {

View File

@ -1,31 +1,33 @@
import React from "react";
import { uploadBytes, ref } from "firebase/storage";
import { nanoid } from "nanoid";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { Card } from "@excalidraw/excalidraw/components/Card";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
import { MIME_TYPES, getFrame } from "@excalidraw/common";
import {
encryptData,
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import type {
FileId,
NonDeletedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
} from "@excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import { nanoid } from "nanoid";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import {
encryptData,
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager";
import { uploadBytes, ref } from "firebase/storage";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],

View File

@ -1,7 +1,8 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import { THEME } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(

View File

@ -1,7 +1,7 @@
import React from "react";
import * as Sentry from "@sentry/browser";
import { t } from "@excalidraw/excalidraw/i18n";
import Trans from "@excalidraw/excalidraw/components/Trans";
import { t } from "@excalidraw/excalidraw/i18n";
import * as Sentry from "@sentry/browser";
import React from "react";
interface TopErrorBoundaryState {
hasError: boolean;

View File

@ -1,14 +1,15 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { compressData } from "@excalidraw/excalidraw/data/encode";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { t } from "@excalidraw/excalidraw/i18n";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "@excalidraw/excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
} from "@excalidraw/element/types";
import type {
BinaryFileData,
BinaryFileMetadata,

View File

@ -10,6 +10,13 @@
* (localStorage, indexedDB).
*/
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import {
createStore,
entries,
@ -19,26 +26,21 @@ import {
setMany,
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "@excalidraw/excalidraw/constants";
import { StoreDelta } from "@excalidraw/excalidraw/store";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
import type {
ExcalidrawElement,
FileId,
} from "@excalidraw/excalidraw/element/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/excalidraw/utility-types";
import { debounce } from "@excalidraw/excalidraw/utils";
import type { DTO, MaybePromise } from "@excalidraw/common/utility-types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
@ -104,13 +106,12 @@ export class LocalData {
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
// saveDataStateToLocalStorage(elements, appState);
// await this.fileStorage.saveFiles({
// elements,
// files,
// });
// onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
@ -256,3 +257,60 @@ export class LibraryLocalStorageMigrationAdapter {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}
type SyncDeltaPersistedData = DTO<StoreDelta>[];
type SyncMetaPersistedData = {
lastAcknowledgedVersion: number;
};
export class SyncIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_SYNC;
/** library data store keys */
private static deltasKey = "deltas";
private static metadataKey = "metadata";
private static store = createStore(
`${SyncIndexedDBAdapter.idb_name}-db`,
`${SyncIndexedDBAdapter.idb_name}-store`,
);
static async loadDeltas() {
const deltas = await get<SyncDeltaPersistedData>(
SyncIndexedDBAdapter.deltasKey,
SyncIndexedDBAdapter.store,
);
if (deltas?.length) {
return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
}
return null;
}
static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
return set(
SyncIndexedDBAdapter.deltasKey,
data,
SyncIndexedDBAdapter.store,
);
}
static async loadMetadata() {
const metadata = await get<SyncMetaPersistedData>(
SyncIndexedDBAdapter.metadataKey,
SyncIndexedDBAdapter.store,
);
return metadata || null;
}
static async saveMetadata(data: SyncMetaPersistedData): Promise<void> {
return set(
SyncIndexedDBAdapter.metadataKey,
data,
SyncIndexedDBAdapter.store,
);
}
}

View File

@ -1,27 +1,12 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { getSceneVersion } from "@excalidraw/excalidraw/element";
import type Portal from "../collab/Portal";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "@excalidraw/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { MIME_TYPES } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
decryptData,
} from "@excalidraw/excalidraw/data/encryption";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import { getSceneVersion } from "@excalidraw/element";
import { initializeApp } from "firebase/app";
import {
getFirestore,
@ -31,8 +16,27 @@ import {
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "@excalidraw/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { getSyncableElements } from ".";
import type { SyncableExcalidrawElement } from ".";
import type Portal from "../collab/Portal";
import type { Socket } from "socket.io-client";
// private
// -----------------------------------------------------------------------------

View File

@ -9,34 +9,38 @@ import {
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { t } from "@excalidraw/excalidraw/i18n";
import { bytesToHexString } from "@excalidraw/common";
import type { UserIdleState } from "@excalidraw/common";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import type { SceneBounds } from "@excalidraw/element/bounds";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
} from "@excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
} from "@excalidraw/excalidraw/types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants";
import type { MakeBrand } from "@excalidraw/excalidraw/utility-types";
import { bytesToHexString } from "@excalidraw/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import type { MakeBrand } from "@excalidraw/common/utility-types";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
import type { WS_SUBTYPES } from "../app_constants";
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;

View File

@ -1,10 +1,12 @@
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import { STORAGE_KEYS } from "../app_constants";
export const saveUsernameToLocalStorage = (username: string) => {

View File

@ -1,9 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./App";
import { registerSW } from "virtual:pwa-register";
import "../excalidraw-app/sentry";
import ExcalidrawApp from "./App";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement);

View File

@ -33,6 +33,7 @@
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "2.11.0",
"rc-slider": "11.1.7",
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",

View File

@ -1,10 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS } from "@excalidraw/excalidraw/keys";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import {
copyIcon,
LinkIcon,
@ -14,16 +12,19 @@ import {
shareIOS,
shareWindows,
} from "@excalidraw/excalidraw/components/icons";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS, getFrame } from "@excalidraw/common";
import { useEffect, useRef, useState } from "react";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss";
import type { CollabAPI } from "../collab/Collab";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";

View File

@ -1,11 +1,11 @@
import ExcalidrawApp from "../App";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
} from "@excalidraw/excalidraw/tests/test-utils";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import ExcalidrawApp from "../App";
describe("Test MobileMenu", () => {
const { h } = window;

View File

@ -1,13 +1,14 @@
import { vi } from "vitest";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { syncInvalidIndices } from "@excalidraw/excalidraw/fractionalIndex";
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
import {
createRedoAction,
createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory";
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest";
import ExcalidrawApp from "../App";
const { h } = window;
@ -121,7 +122,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
const undoAction = createUndoAction(h.history);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
@ -153,7 +154,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
const redoAction = createRedoAction(h.history);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!

View File

@ -1,8 +1,9 @@
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "@excalidraw/excalidraw";
import { EVENT } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { CODES, KEYS } from "@excalidraw/excalidraw/keys";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
import { STORAGE_KEYS } from "./app_constants";
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>

View File

@ -23,29 +23,57 @@ export default defineConfig(({ mode }) => {
envDir: "../",
resolve: {
alias: [
{
find: /^@excalidraw\/common$/,
replacement: path.resolve(
__dirname,
"../packages/common/src/index.ts",
),
},
{
find: /^@excalidraw\/common\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/common/src/$1"),
},
{
find: /^@excalidraw\/element$/,
replacement: path.resolve(
__dirname,
"../packages/element/src/index.ts",
),
},
{
find: /^@excalidraw\/element\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/element/src/$1"),
},
{
find: /^@excalidraw\/excalidraw$/,
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
},
{
find: /^@excalidraw\/excalidraw\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/excalidraw/$1"),
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(__dirname, "../packages/utils/index.ts"),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/$1"),
},
{
find: /^@excalidraw\/math$/,
replacement: path.resolve(__dirname, "../packages/math/index.ts"),
replacement: path.resolve(__dirname, "../packages/math/src/index.ts"),
},
{
find: /^@excalidraw\/math\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/math/$1"),
replacement: path.resolve(__dirname, "../packages/math/src/$1"),
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
},
],
},
@ -197,7 +225,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id:"excalidraw",
id: "excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",

View File

@ -4,9 +4,7 @@
"packageManager": "yarn@1.22.22",
"workspaces": [
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
"packages/math",
"packages/*",
"examples/*"
],
"devDependencies": {
@ -26,6 +24,7 @@
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",

View File

@ -0,0 +1,3 @@
{
"extends": ["../eslintrc.base.json"]
}

19
packages/common/README.md Normal file
View File

@ -0,0 +1,19 @@
# @excalidraw/common
## Install
```bash
npm install @excalidraw/common
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/common
```
With PNPM, similarly install the package with this command:
```bash
pnpm add @excalidraw/common
```

3
packages/common/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@ -0,0 +1,56 @@
{
"name": "@excalidraw/common",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/common/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./../common/dist/types/common/src/*.d.ts"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw common functions, constants, etc.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -1,4 +1,4 @@
export default class BinaryHeap<T> {
export class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}

View File

@ -1,6 +1,9 @@
import oc from "open-color";
import type { Merge } from "./utility-types";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,

View File

@ -1,5 +1,9 @@
import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import type {
ExcalidrawElement,
FontFamilyValues,
} from "@excalidraw/element/types";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@ -108,6 +112,7 @@ export const YOUTUBE_STATES = {
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
PRODUCTION: "production",
};
export const CLASSES = {
@ -314,6 +319,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`;
export const ENCRYPTION_KEY_BITS = 128;
@ -415,6 +423,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
lasso: "lasso",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",

View File

@ -1,11 +1,9 @@
import type { JSX } from "react";
import {
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyHeadingIcon,
FontFamilyCodeIcon,
} from "../components/icons";
import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "../constants";
import type {
ExcalidrawTextElement,
FontFamilyValues,
} from "@excalidraw/element/types";
import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "./constants";
/**
* Encapsulates font metrics with additional font metadata.
@ -22,8 +20,6 @@ export interface FontMetadata {
/** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
lineHeight: number;
};
/** element to be displayed as an icon */
icon?: JSX.Element;
/** flag to indicate a deprecated font */
deprecated?: true;
/** flag to indicate a server-side only font */
@ -42,7 +38,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -374,
lineHeight: 1.25,
},
icon: FreedrawIcon,
},
[FONT_FAMILY.Nunito]: {
metrics: {
@ -51,7 +46,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -353,
lineHeight: 1.35,
},
icon: FontFamilyNormalIcon,
},
[FONT_FAMILY["Lilita One"]]: {
metrics: {
@ -60,7 +54,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -220,
lineHeight: 1.15,
},
icon: FontFamilyHeadingIcon,
},
[FONT_FAMILY["Comic Shanns"]]: {
metrics: {
@ -69,7 +62,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -250,
lineHeight: 1.25,
},
icon: FontFamilyCodeIcon,
},
[FONT_FAMILY.Virgil]: {
metrics: {
@ -78,7 +70,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -374,
lineHeight: 1.25,
},
icon: FreedrawIcon,
deprecated: true,
},
[FONT_FAMILY.Helvetica]: {
@ -88,7 +79,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -471,
lineHeight: 1.15,
},
icon: FontFamilyNormalIcon,
deprecated: true,
local: true,
},
@ -99,7 +89,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -480,
lineHeight: 1.2,
},
icon: FontFamilyCodeIcon,
deprecated: true,
},
[FONT_FAMILY["Liberation Sans"]]: {
@ -148,3 +137,34 @@ export const GOOGLE_FONTS_RANGES = {
/** local protocol to skip the local font from registering or inlining */
export const LOCAL_FONT_PROTOCOL = "local:";
/**
* 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_METADATA[fontFamily]?.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
const fontSizeEm = fontSize / unitsPerEm;
const lineGap =
(lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
/**
* Gets line height for a selected family.
*/
export const getLineHeight = (fontFamily: FontFamilyValues) => {
const { lineHeight } =
FONT_METADATA[fontFamily]?.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
return lineHeight as ExcalidrawTextElement["lineHeight"];
};

View File

@ -0,0 +1,11 @@
export * from "./binary-heap";
export * from "./colors";
export * from "./constants";
export * from "./font-metadata";
export * from "./queue";
export * from "./keys";
export * from "./points";
export * from "./promise-pool";
export * from "./random";
export * from "./url";
export * from "./utils";

View File

@ -1,4 +1,5 @@
import { isDarwin } from "./constants";
import type { ValueOf } from "./utility-types";
export const CODES = {

View File

@ -4,6 +4,8 @@ import {
type LocalPoint,
} from "@excalidraw/math";
import type { NullableGridSize } from "@excalidraw/excalidraw/types";
export const getSizeFromPoints = (
points: readonly (GlobalPoint | LocalPoint)[],
) => {
@ -61,3 +63,18 @@ export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
return nextPoints;
};
// TODO: Rounding this point causes some shake when free drawing
export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
if (gridSize) {
return [
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
}
return [x, y];
};

View File

@ -0,0 +1,50 @@
import Pool from "es6-promise-pool";
// extending the missing types
// relying on the [Index, T] to keep a correct order
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
addEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => (event: { data: { result: [Index, T] } }) => void;
removeEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => void;
};
export class PromisePool<T> {
private readonly pool: TPromisePool<T>;
private readonly entries: Record<number, T> = {};
constructor(
source: IterableIterator<Promise<void | readonly [number, T]>>,
concurrency: number,
) {
this.pool = new Pool(
source as unknown as () => void | PromiseLike<[number, T][]>,
concurrency,
) as TPromisePool<T>;
}
public all() {
const listener = (event: { data: { result: void | [number, T] } }) => {
if (event.data.result) {
// by default pool does not return the results, so we are gathering them manually
// with the correct call order (represented by the index in the tuple)
const [index, value] = event.data.result;
this.entries[index] = value;
}
};
this.pool.addEventListener("fulfilled", listener);
return this.pool.start().then(() => {
setTimeout(() => {
this.pool.removeEventListener("fulfilled", listener);
});
return Object.values(this.entries);
});
}
}

View File

@ -1,6 +1,8 @@
import { promiseTry, resolvablePromise } from ".";
import type { ResolvablePromise } from ".";
import type { MaybePromise } from "./utility-types";
import type { ResolvablePromise } from "./utils";
import { promiseTry, resolvablePromise } from "./utils";
type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>;

View File

@ -1,5 +1,6 @@
import { Random } from "roughjs/bin/math";
import { nanoid } from "nanoid";
import { Random } from "roughjs/bin/math";
import { isTestEnv } from "./utils";
let random = new Random(Date.now());

View File

@ -1,5 +1,6 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { escapeDoubleQuotes } from "../utils";
import { escapeDoubleQuotes } from "./utils";
export const normalizeLink = (link: string) => {
link = link.trim();

View File

@ -66,5 +66,10 @@ export type MakeBrand<T extends string> = {
/** Maybe just promise or already fulfilled one! */
export type MaybePromise<T> = T | Promise<T>;
/** Strip all the methods or functions from a type */
export type DTO<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
// get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never;

View File

@ -1,28 +1,34 @@
import Pool from "es6-promise-pool";
import { average } from "@excalidraw/math";
import { COLOR_PALETTE } from "./colors";
import type { EVENT } from "./constants";
import {
DEFAULT_VERSION,
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "./element/types";
ExcalidrawElement,
} from "@excalidraw/element/types";
import type {
ActiveTool,
AppState,
ToolType,
UnsubscribeCallback,
Zoom,
} from "./types";
} from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_VERSION,
ENV,
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
import type { MaybePromise, ResolutionType } from "./utility-types";
import type { EVENT } from "./constants";
let mockDateTime: string | null = null;
export const setDateTimeForTests = (dateTime: string) => {
@ -167,7 +173,7 @@ export const throttleRAF = <T extends any[]>(
};
const ret = (...args: T) => {
if (import.meta.env.MODE === "test") {
if (isTestEnv()) {
fn(...args);
return;
}
@ -380,7 +386,7 @@ export const updateActiveTool = (
type: ToolType;
}
| { type: "custom"; customType: string }
) & { locked?: boolean }) & {
) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null;
},
): AppState["activeTool"] => {
@ -402,6 +408,7 @@ export const updateActiveTool = (
type: data.type,
customType: null,
locked: data.locked ?? appState.activeTool.locked,
fromSelection: data.fromSelection ?? false,
};
};
@ -673,7 +680,7 @@ export const arrayToMap = <T extends { id: string } | string>(
return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
}, new Map());
}, new Map() as Map<string, T>);
};
export const arrayToMapWithIndex = <T extends { id: string }>(
@ -728,9 +735,11 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc;
}, [] as Node<T>[]);
export const isTestEnv = () => import.meta.env.MODE === "test";
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === "development";
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
export const isServerEnv = () =>
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
@ -1184,54 +1193,6 @@ export const safelyParseJSON = (json: string): Record<string, any> | null => {
return null;
}
};
// extending the missing types
// relying on the [Index, T] to keep a correct order
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
addEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => (event: { data: { result: [Index, T] } }) => void;
removeEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => void;
};
export class PromisePool<T> {
private readonly pool: TPromisePool<T>;
private readonly entries: Record<number, T> = {};
constructor(
source: IterableIterator<Promise<void | readonly [number, T]>>,
concurrency: number,
) {
this.pool = new Pool(
source as unknown as () => void | PromiseLike<[number, T][]>,
concurrency,
) as TPromisePool<T>;
}
public all() {
const listener = (event: { data: { result: void | [number, T] } }) => {
if (event.data.result) {
// by default pool does not return the results, so we are gathering them manually
// with the correct call order (represented by the index in the tuple)
const [index, value] = event.data.result;
this.entries[index] = value;
}
};
this.pool.addEventListener("fulfilled", listener);
return this.pool.start().then(() => {
setTimeout(() => {
this.pool.removeEventListener("fulfilled", listener);
});
return Object.values(this.entries);
});
}
}
/**
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
@ -1243,3 +1204,32 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value);
};
export const sizeOf = (
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
): number => {
return isReadonlyArray(value)
? value.length
: value instanceof Map
? value.size
: Object.keys(value).length;
};

View File

@ -1,4 +1,4 @@
import { KEYS, matchKey } from "./keys";
import { KEYS, matchKey } from "../src/keys";
describe("key matcher", async () => {
it("should not match unexpected key", async () => {

View File

@ -1,4 +1,4 @@
import { Queue } from "./queue";
import { Queue } from "../src/queue";
describe("Queue", () => {
const calls: any[] = [];

View File

@ -1,4 +1,4 @@
import { normalizeLink } from "./url";
import { normalizeLink } from "../src/url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}

View File

@ -0,0 +1,38 @@
{
"name": "@excalidraw/deltas",
"version": "0.0.1",
"main": "./dist/prod/index.js",
"type": "module",
"module": "./dist/prod/index.js",
"exports": {
".": {
"development": "./dist/dev/index.js",
"default": "./dist/prod/index.js"
}
},
"types": "./dist/types/index.d.ts",
"files": [
"dist/*"
],
"description": "Excalidraw utilities for handling deltas",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-deltas"
],
"dependencies": {
"nanoid": "5.0.9",
"roughjs": "4.6.6"
},
"devDependencies": {},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildShared.js && yarn gen:types",
"pack": "yarn build:umd && yarn pack"
}
}

View File

@ -0,0 +1,357 @@
import { arrayToObject, assertNever } from "./utils";
/**
* Represents the difference between two objects of the same type.
*
* Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where:
* - `deleted` is a set of all the deleted values
* - `inserted` is a set of all the inserted (added, updated) values
*
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
*/
export class Delta<T> {
private constructor(
public readonly deleted: Partial<T>,
public readonly inserted: Partial<T>,
) {}
public static create<T>(
deleted: Partial<T>,
inserted: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>,
modifierOptions?: "deleted" | "inserted",
) {
const modifiedDeleted =
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
const modifiedInserted =
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
return new Delta(modifiedDeleted, modifiedInserted);
}
/**
* Calculates the delta between two objects.
*
* @param prevObject - The previous state of the object.
* @param nextObject - The next state of the object.
*
* @returns new delta instance.
*/
public static calculate<T extends { [key: string]: any }>(
prevObject: T,
nextObject: T,
modifier?: (partial: Partial<T>) => Partial<T>,
postProcess?: (
deleted: Partial<T>,
inserted: Partial<T>,
) => [Partial<T>, Partial<T>],
): Delta<T> {
if (prevObject === nextObject) {
return Delta.empty();
}
const deleted = {} as Partial<T>;
const inserted = {} as Partial<T>;
// O(n^3) here for elements, but it's not as bad as it looks:
// - we do this only on store recordings, not on every frame (not for ephemerals)
// - we do this only on previously detected changed elements
// - we do shallow compare only on the first level of properties (not going any deeper)
// - # of properties is reasonably small
for (const key of this.distinctKeysIterator(
"full",
prevObject,
nextObject,
)) {
deleted[key as keyof T] = prevObject[key];
inserted[key as keyof T] = nextObject[key];
}
const [processedDeleted, processedInserted] = postProcess
? postProcess(deleted, inserted)
: [deleted, inserted];
return Delta.create(processedDeleted, processedInserted, modifier);
}
public static empty() {
return new Delta({}, {});
}
public static isEmpty<T>(delta: Delta<T>): boolean {
return (
!Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length
);
}
/**
* Merges deleted and inserted object partials.
*/
public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T,
added: T,
removed: T,
) {
const cloned = { ...prev };
for (const key of Object.keys(removed)) {
delete cloned[key];
}
return { ...cloned, ...added };
}
/**
* Merges deleted and inserted array partials.
*/
public static mergeArrays<T>(
prev: readonly T[] | null,
added: readonly T[] | null | undefined,
removed: readonly T[] | null | undefined,
predicate?: (value: T) => string,
) {
return Object.values(
Delta.mergeObjects(
arrayToObject(prev ?? [], predicate),
arrayToObject(added ?? [], predicate),
arrayToObject(removed ?? [], predicate),
),
);
}
/**
* Diff object partials as part of the `postProcess`.
*/
public static diffObjects<T, K extends keyof T, V extends T[K][keyof T[K]]>(
deleted: Partial<T>,
inserted: Partial<T>,
property: K,
setValue: (prevValue: V | undefined) => V,
) {
if (!deleted[property] && !inserted[property]) {
return;
}
if (
typeof deleted[property] === "object" ||
typeof inserted[property] === "object"
) {
type RecordLike = Record<string, V | undefined>;
const deletedObject: RecordLike = deleted[property] ?? {};
const insertedObject: RecordLike = inserted[property] ?? {};
const deletedDifferences = Delta.getLeftDifferences(
deletedObject,
insertedObject,
).reduce((acc, curr) => {
acc[curr] = setValue(deletedObject[curr]);
return acc;
}, {} as RecordLike);
const insertedDifferences = Delta.getRightDifferences(
deletedObject,
insertedObject,
).reduce((acc, curr) => {
acc[curr] = setValue(insertedObject[curr]);
return acc;
}, {} as RecordLike);
if (
Object.keys(deletedDifferences).length ||
Object.keys(insertedDifferences).length
) {
Reflect.set(deleted, property, deletedDifferences);
Reflect.set(inserted, property, insertedDifferences);
} else {
Reflect.deleteProperty(deleted, property);
Reflect.deleteProperty(inserted, property);
}
}
}
/**
* Diff array partials as part of the `postProcess`.
*/
public static diffArrays<T, K extends keyof T, V extends T[K]>(
deleted: Partial<T>,
inserted: Partial<T>,
property: K,
groupBy: (value: V extends ArrayLike<infer T> ? T : never) => string,
) {
if (!deleted[property] && !inserted[property]) {
return;
}
if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) {
const deletedArray = (
Array.isArray(deleted[property]) ? deleted[property] : []
) as [];
const insertedArray = (
Array.isArray(inserted[property]) ? inserted[property] : []
) as [];
const deletedDifferences = arrayToObject(
Delta.getLeftDifferences(
arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy),
),
);
const insertedDifferences = arrayToObject(
Delta.getRightDifferences(
arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy),
),
);
if (
Object.keys(deletedDifferences).length ||
Object.keys(insertedDifferences).length
) {
const deletedValue = deletedArray.filter(
(x) => deletedDifferences[groupBy ? groupBy(x) : String(x)],
);
const insertedValue = insertedArray.filter(
(x) => insertedDifferences[groupBy ? groupBy(x) : String(x)],
);
Reflect.set(deleted, property, deletedValue);
Reflect.set(inserted, property, insertedValue);
} else {
Reflect.deleteProperty(deleted, property);
Reflect.deleteProperty(inserted, property);
}
}
}
/**
* Compares if object1 contains any different value compared to the object2.
*/
public static isLeftDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = this.distinctKeysIterator(
"left",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Compares if object2 contains any different value compared to the object1.
*/
public static isRightDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = this.distinctKeysIterator(
"right",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Returns all the object1 keys that have distinct values.
*/
public static getLeftDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
);
}
/**
* Returns all the object2 keys that have distinct values.
*/
public static getRightDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
);
}
/**
* Iterator comparing values of object properties based on the passed joining strategy.
*
* @yields keys of properties with different values
*
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
*/
private static *distinctKeysIterator<T extends {}>(
join: "left" | "right" | "full",
object1: T,
object2: T,
skipShallowCompare = false,
) {
if (object1 === object2) {
return;
}
let keys: string[] = [];
if (join === "left") {
keys = Object.keys(object1);
} else if (join === "right") {
keys = Object.keys(object2);
} else if (join === "full") {
keys = Array.from(
new Set([...Object.keys(object1), ...Object.keys(object2)]),
);
} else {
assertNever(join, "Unknown distinctKeysIterator's join param");
}
for (const key of keys) {
const object1Value = object1[key as keyof T];
const object2Value = object2[key as keyof T];
if (object1Value !== object2Value) {
if (
!skipShallowCompare &&
typeof object1Value === "object" &&
typeof object2Value === "object" &&
object1Value !== null &&
object2Value !== null &&
this.isShallowEqual(object1Value, object2Value)
) {
continue;
}
yield key;
}
}
}
private static isShallowEqual(object1: any, object2: any) {
const keys1 = Object.keys(object1);
const keys2 = Object.keys(object1);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
if (object1[key] !== object2[key]) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,21 @@
/**
* Encapsulates a set of application-level `Delta`s.
*/
export interface DeltaContainer<T> {
/**
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
*/
inverse(): DeltaContainer<T>;
/**
* Applies the `Delta`s to the previous object.
*
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
*/
applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Checks whether all `Delta`s are empty.
*/
isEmpty(): boolean;
}

View File

@ -0,0 +1,149 @@
import { Random } from "roughjs/bin/math";
import { nanoid } from "nanoid";
import type {
AppState,
ObservedAppState,
ElementsMap,
ExcalidrawElement,
ElementUpdate,
} from "../excalidraw-types";
/**
* Transform array into an object, use only when array order is irrelevant.
*/
export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string | number,
) =>
array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : String(value)] = value;
return acc;
}, {} as { [key: string]: T });
/**
* Transforms array of elements with `id` property into into a Map grouped by `id`.
*/
export const elementsToMap = <T extends { id: string }>(
items: readonly T[],
) => {
return items.reduce((acc: Map<string, T>, element) => {
acc.set(element.id, element);
return acc;
}, new Map());
};
// --
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
// ------------------------------------------------------------
export const assertNever = (value: never, message: string): never => {
throw new Error(`${message}: "${value}".`);
};
// ------------------------------------------------------------
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
const nonDeletedGroupIds = new Set<string>();
for (const [, element] of elements) {
// defensive check
if (element.isDeleted) {
continue;
}
// defensive fallback
for (const groupId of element.groupIds ?? []) {
nonDeletedGroupIds.add(groupId);
}
}
return nonDeletedGroupIds;
};
// ------------------------------------------------------------
export const isTestEnv = () => import.meta.env.MODE === "test";
export const isDevEnv = () => import.meta.env.MODE === "development";
export const isServerEnv = () => import.meta.env.MODE === "server";
export const shouldThrow = () => isDevEnv() || isTestEnv() || isServerEnv();
// ------------------------------------------------------------
let random = new Random(Date.now());
let testIdBase = 0;
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
export const reseed = (seed: number) => {
random = new Random(seed);
testIdBase = 0;
};
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
// ------------------------------------------------------------
export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
// ------------------------------------------------------------
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
/** pass `true` to always regenerate */
force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}
didChange = true;
}
}
if (!didChange && !force) {
return element;
}
return {
...element,
...updates,
updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
};
};

View File

@ -0,0 +1,404 @@
import { Delta } from "../common/delta";
import {
assertNever,
getNonDeletedGroupIds,
getObservedAppState,
isDevEnv,
isTestEnv,
shouldThrow,
} from "../common/utils";
import type { DeltaContainer } from "../common/interfaces";
import type {
AppState,
ObservedAppState,
DTO,
SceneElementsMap,
ValueOf,
ObservedElementsAppState,
ObservedStandaloneAppState,
SubtypeOf,
} from "../excalidraw-types";
export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>(
prevAppState: T,
nextAppState: T,
): AppStateDelta {
const delta = Delta.calculate(
prevAppState,
nextAppState,
undefined,
AppStateDelta.postProcess,
);
return new AppStateDelta(delta);
}
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
const { delta } = appStateDeltaDTO;
return new AppStateDelta(delta);
}
public static empty() {
return new AppStateDelta(Delta.create({}, {}));
}
public inverse(): AppStateDelta {
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
return new AppStateDelta(inversedDelta);
}
public applyTo(
appState: AppState,
nextElements: SceneElementsMap,
): [AppState, boolean] {
try {
const {
selectedElementIds: removedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {},
} = this.delta.deleted;
const {
selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {},
selectedLinearElementId,
editingLinearElementId,
...directlyApplicablePartial
} = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds,
addedSelectedElementIds,
removedSelectedElementIds,
);
const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds,
addedSelectedGroupIds,
removedSelectedGroupIds,
);
// const selectedLinearElement =
// selectedLinearElementId && nextElements.has(selectedLinearElementId)
// ? new LinearElementEditor(
// nextElements.get(
// selectedLinearElementId,
// ) as NonDeleted<ExcalidrawLinearElement>,
// )
// : null;
// const editingLinearElement =
// editingLinearElementId && nextElements.has(editingLinearElementId)
// ? new LinearElementEditor(
// nextElements.get(
// editingLinearElementId,
// ) as NonDeleted<ExcalidrawLinearElement>,
// )
// : null;
const nextAppState = {
...appState,
...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds,
// selectedLinearElement:
// typeof selectedLinearElementId !== "undefined"
// ? selectedLinearElement // element was either inserted or deleted
// : appState.selectedLinearElement, // otherwise assign what we had before
// editingLinearElement:
// typeof editingLinearElementId !== "undefined"
// ? editingLinearElement // element was either inserted or deleted
// : appState.editingLinearElement, // otherwise assign what we had before
};
const constainsVisibleChanges = this.filterInvisibleChanges(
appState,
nextAppState,
nextElements,
);
return [nextAppState, constainsVisibleChanges];
} catch (e) {
// shouldn't really happen, but just in case
console.error(`Couldn't apply appstate delta`, e);
if (shouldThrow()) {
throw e;
}
return [appState, false];
}
}
public isEmpty(): boolean {
return Delta.isEmpty(this.delta);
}
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess<T extends ObservedAppState>(
deleted: Partial<T>,
inserted: Partial<T>,
): [Partial<T>, Partial<T>] {
try {
Delta.diffObjects(
deleted,
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"selectedGroupIds",
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
if (isDevEnv() || isTestEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
/**
* Mutates `nextAppState` be filtering out state related to deleted elements.
*
* @returns `true` if a visible change is found, `false` otherwise.
*/
private filterInvisibleChanges(
prevAppState: AppState,
nextAppState: AppState,
nextElements: SceneElementsMap,
): boolean {
// TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements
// which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates
const prevObservedAppState = getObservedAppState(prevAppState);
const nextObservedAppState = getObservedAppState(nextAppState);
const containsStandaloneDifference = Delta.isRightDifferent(
AppStateDelta.stripElementsProps(prevObservedAppState),
AppStateDelta.stripElementsProps(nextObservedAppState),
);
const containsElementsDifference = Delta.isRightDifferent(
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
);
if (!containsStandaloneDifference && !containsElementsDifference) {
// no change in appstate was detected
return false;
}
const visibleDifferenceFlag = {
value: containsStandaloneDifference,
};
if (containsElementsDifference) {
// filter invisible changes on each iteration
const changedElementsProps = Delta.getRightDifferences(
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
) as Array<keyof ObservedElementsAppState>;
let nonDeletedGroupIds = new Set<string>();
if (
changedElementsProps.includes("editingGroupId") ||
changedElementsProps.includes("selectedGroupIds")
) {
// this one iterates through all the non deleted elements, so make sure it's not done twice
nonDeletedGroupIds = getNonDeletedGroupIds(nextElements);
}
// check whether delta properties are related to the existing non-deleted elements
for (const key of changedElementsProps) {
switch (key) {
case "selectedElementIds":
nextAppState[key] = AppStateDelta.filterSelectedElements(
nextAppState[key],
nextElements,
visibleDifferenceFlag,
);
break;
case "selectedGroupIds":
nextAppState[key] = AppStateDelta.filterSelectedGroups(
nextAppState[key],
nonDeletedGroupIds,
visibleDifferenceFlag,
);
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];
if (!editingGroupId) {
// previously there was an editingGroup (assuming visible), now there is none
visibleDifferenceFlag.value = true;
} else if (nonDeletedGroupIds.has(editingGroupId)) {
// previously there wasn't an editingGroup, now there is one which is visible
visibleDifferenceFlag.value = true;
} else {
// there was assigned an editingGroup now, but it's related to deleted element
nextAppState[key] = null;
}
break;
case "selectedLinearElementId":
case "editingLinearElementId":
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) {
// previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true;
} else {
const element = nextElements.get(linearElement.elementId);
if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true;
} else {
// there was assigned a linear element now, but it's deleted
nextAppState[appStateKey] = null;
}
}
break;
default: {
assertNever(key, `Unknown ObservedElementsAppState's key "${key}"`);
}
}
}
}
return visibleDifferenceFlag.value;
}
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap,
visibleDifferenceFlag: { value: boolean },
) {
const ids = Object.keys(selectedElementIds);
if (!ids.length) {
// previously there were ids (assuming related to visible elements), now there are none
visibleDifferenceFlag.value = true;
return selectedElementIds;
}
const nextSelectedElementIds = { ...selectedElementIds };
for (const id of ids) {
const element = elements.get(id);
if (element && !element.isDeleted) {
// there is a selected element id related to a visible element
visibleDifferenceFlag.value = true;
} else {
delete nextSelectedElementIds[id];
}
}
return nextSelectedElementIds;
}
private static filterSelectedGroups(
selectedGroupIds: AppState["selectedGroupIds"],
nonDeletedGroupIds: Set<string>,
visibleDifferenceFlag: { value: boolean },
) {
const ids = Object.keys(selectedGroupIds);
if (!ids.length) {
// previously there were ids (assuming related to visible groups), now there are none
visibleDifferenceFlag.value = true;
return selectedGroupIds;
}
const nextSelectedGroupIds = { ...selectedGroupIds };
for (const id of Object.keys(nextSelectedGroupIds)) {
if (nonDeletedGroupIds.has(id)) {
// there is a selected group id related to a visible group
visibleDifferenceFlag.value = true;
} else {
delete nextSelectedGroupIds[id];
}
}
return nextSelectedGroupIds;
}
private static stripElementsProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedStandaloneAppState> {
// WARN: Do not remove the type-casts as they here to ensure proper type checks
const {
editingGroupId,
selectedGroupIds,
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;
return standaloneProps as SubtypeOf<
typeof standaloneProps,
ObservedStandaloneAppState
>;
}
private static stripStandaloneProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedElementsAppState> {
// WARN: Do not remove the type-casts as they here to ensure proper type checks
const { name, viewBackgroundColor, ...elementsProps } =
delta as ObservedAppState;
return elementsProps as SubtypeOf<
typeof elementsProps,
ObservedElementsAppState
>;
}
}

View File

@ -0,0 +1,825 @@
import { Delta } from "../common/delta";
import { newElementWith, shouldThrow } from "../common/utils";
import type { DeltaContainer } from "../common/interfaces";
import type {
ExcalidrawElement,
ElementUpdate,
Ordered,
SceneElementsMap,
DTO,
OrderedExcalidrawElement,
ExcalidrawImageElement,
} from "../excalidraw-types";
// CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>;
/**
* Elements delta is a low level primitive to encapsulate property changes between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
*/
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private constructor(
public readonly added: Record<string, Delta<ElementPartial>>,
public readonly removed: Record<string, Delta<ElementPartial>>,
public readonly updated: Record<string, Delta<ElementPartial>>,
) {}
public static create(
added: Record<string, Delta<ElementPartial>>,
removed: Record<string, Delta<ElementPartial>>,
updated: Record<string, Delta<ElementPartial>>,
options: {
shouldRedistribute: boolean;
} = {
shouldRedistribute: false,
// CFDO: don't forget to re-enable
},
) {
const { shouldRedistribute } = options;
let delta: ElementsDelta;
if (shouldRedistribute) {
const nextAdded: Record<string, Delta<ElementPartial>> = {};
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
const deltas = [
...Object.entries(added),
...Object.entries(removed),
...Object.entries(updated),
];
for (const [id, delta] of deltas) {
if (this.satisfiesAddition(delta)) {
nextAdded[id] = delta;
} else if (this.satisfiesRemoval(delta)) {
nextRemoved[id] = delta;
} else {
nextUpdated[id] = delta;
}
}
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
} else {
delta = new ElementsDelta(added, removed, updated);
}
if (shouldThrow()) {
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
}
return delta;
}
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
const { added, removed, updated } = elementsDeltaDTO;
return ElementsDelta.create(added, removed, updated);
}
private static satisfiesAddition = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
// dissallowing added as "deleted", which could cause issues when resolving conflicts
deleted.isDeleted === true && !inserted.isDeleted;
private static satisfiesRemoval = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
!deleted.isDeleted && inserted.isDeleted === true;
private static satisfiesUpdate = ({
deleted,
inserted,
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static validate(
elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean,
) {
for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) {
console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`,
delta,
);
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
}
}
}
/**
* Calculates the `Delta`s between the previous and next set of elements.
*
* @param prevElements - Map representing the previous state of elements.
* @param nextElements - Map representing the next state of elements.
*
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
*/
public static calculate<T extends OrderedExcalidrawElement>(
prevElements: Map<string, T>,
nextElements: Map<string, T>,
): ElementsDelta {
if (prevElements === nextElements) {
return ElementsDelta.empty();
}
const added: Record<string, Delta<ElementPartial>> = {};
const removed: Record<string, Delta<ElementPartial>> = {};
const updated: Record<string, Delta<ElementPartial>> = {};
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
for (const prevElement of prevElements.values()) {
const nextElement = nextElements.get(prevElement.id);
if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { isDeleted: true } as ElementPartial;
const delta = Delta.create(
deleted,
inserted,
ElementsDelta.stripIrrelevantProps,
);
removed[prevElement.id] = delta;
}
}
for (const nextElement of nextElements.values()) {
const prevElement = prevElements.get(nextElement.id);
if (!prevElement) {
const deleted = { isDeleted: true } as ElementPartial;
const inserted = {
...nextElement,
isDeleted: false,
} as ElementPartial;
const delta = Delta.create(
deleted,
inserted,
ElementsDelta.stripIrrelevantProps,
);
added[nextElement.id] = delta;
continue;
}
if (prevElement.versionNonce !== nextElement.versionNonce) {
const delta = Delta.calculate<ElementPartial>(
prevElement,
nextElement,
ElementsDelta.stripIrrelevantProps,
ElementsDelta.postProcess,
);
if (
// making sure we don't get here some non-boolean values (i.e. undefined, null, etc.)
typeof prevElement.isDeleted === "boolean" &&
typeof nextElement.isDeleted === "boolean" &&
prevElement.isDeleted !== nextElement.isDeleted
) {
// notice that other props could have been updated as well
if (prevElement.isDeleted && !nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
removed[nextElement.id] = delta;
}
continue;
}
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
}
}
return ElementsDelta.create(added, removed, updated);
}
public static empty() {
return ElementsDelta.create({}, {}, {});
}
public inverse(): ElementsDelta {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
}
return inversedDeltas;
};
const added = inverseInternal(this.added);
const removed = inverseInternal(this.removed);
const updated = inverseInternal(this.updated);
// notice we inverse removed with added not to break the invariants
// notice we force generate a new id
return ElementsDelta.create(removed, added, updated);
}
public isEmpty(): boolean {
return (
Object.keys(this.added).length === 0 &&
Object.keys(this.removed).length === 0 &&
Object.keys(this.updated).length === 0
);
}
/**
* Update delta/s based on the existing elements.
*
* @param elements current elements
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): ElementsDelta {
const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
// do not update following props:
// - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys
switch (key) {
case "boundElements":
latestPartial[key] = partial[key];
break;
default:
latestPartial[key] = element[key];
}
}
return latestPartial;
};
const applyLatestChangesInternal = (
deltas: Record<string, Delta<ElementPartial>>,
) => {
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id);
if (existingElement) {
const modifiedDelta = Delta.create(
delta.deleted,
delta.inserted,
modifier(existingElement),
modifierOptions,
);
modifiedDeltas[id] = modifiedDelta;
} else {
modifiedDeltas[id] = delta;
}
}
return modifiedDeltas;
};
const added = applyLatestChangesInternal(this.added);
const removed = applyLatestChangesInternal(this.removed);
const updated = applyLatestChangesInternal(this.updated);
return ElementsDelta.create(added, removed, updated, {
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
});
}
// CFDO: does it make sense having a separate snapshot?
public applyTo(
elements: SceneElementsMap,
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
): [SceneElementsMap, boolean] {
const nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = {
containsVisibleDifference: false,
containsZindexDifference: false,
};
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try {
const applyDeltas = ElementsDelta.createApplier(
nextElements,
elementsSnapshot,
flags,
);
const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas("updated", this.updated);
// CFDO I: don't forget to fix this part
// const affectedElements = this.resolveConflicts(elements, nextElements);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([
...addedElements,
...removedElements,
...updatedElements,
// ...affectedElements,
]);
} catch (e) {
console.error(`Couldn't apply elements delta`, e);
if (shouldThrow()) {
throw e;
}
// should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true`
// even though there is obviously no visible change, returning `false` could be dangerous, as i.e.:
// in the worst case, it could lead into iterating through the whole stack with no possibility to redo
// instead, the worst case when returning `true` is an empty undo / redo
return [elements, true];
}
try {
// CFDO I: don't forget to fix this part
// // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
// ElementsDelta.redrawTextBoundingBoxes(nextElements, changedElements);
// // the following reorder performs also mutations, but only on new instances of changed elements
// // (unless something goes really bad and it fallbacks to fixing all invalid indices)
// nextElements = ElementsDelta.reorderElements(
// nextElements,
// changedElements,
// flags,
// );
// // Need ordered nextElements to avoid z-index binding issues
// ElementsDelta.redrawBoundArrows(nextElements, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
e,
);
if (shouldThrow()) {
throw e;
}
} finally {
return [nextElements, flags.containsVisibleDifference];
}
}
private static createApplier =
(
nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter(
type,
nextElements,
snapshot,
flags,
);
return Object.entries(deltas).reduce((acc, [id, delta]) => {
const element = getElement(id, delta.inserted);
if (element) {
const newElement = ElementsDelta.applyDelta(element, delta, flags);
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
}
return acc;
}, new Map<string, OrderedExcalidrawElement>());
};
private static createGetter =
(
type: "added" | "removed" | "updated",
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(id: string, partial: ElementPartial) => {
let element = elements.get(id);
if (!element) {
// always fallback to the local snapshot, in cases when we cannot find the element in the elements array
element = snapshot.get(id);
if (element) {
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change
if (
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true;
}
} else if (type === "added") {
// for additions the element does not have to exist (i.e. remote update)
// CFDO II: the version itself might be different!
element = newElementWith(
{ id, version: 1 } as OrderedExcalidrawElement,
{
...partial,
},
);
}
}
return element;
};
private static applyDelta(
element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) {
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
if (
delta.deleted.boundElements?.length ||
delta.inserted.boundElements?.length
) {
const mergedBoundElements = Delta.mergeArrays(
element.boundElements,
delta.inserted.boundElements,
delta.deleted.boundElements,
(x) => x.id,
);
Object.assign(directlyApplicablePartial, {
boundElements: mergedBoundElements,
});
}
// CFDO: this looks wrong
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
element,
rest,
);
flags.containsVisibleDifference = containsVisibleDifference;
}
if (!flags.containsZindexDifference) {
flags.containsZindexDifference =
delta.deleted.index !== delta.inserted.index;
}
return newElementWith(element, directlyApplicablePartial);
}
/**
* Check for visible changes regardless of whether they were removed, added or updated.
*/
private static checkForVisibleDifference(
element: OrderedExcalidrawElement,
partial: ElementPartial,
) {
if (element.isDeleted && partial.isDeleted !== false) {
// when it's deleted and partial is not false, it cannot end up with a visible change
return false;
}
if (element.isDeleted && partial.isDeleted === false) {
// when we add an element, it results in a visible change
return true;
}
if (element.isDeleted === false && partial.isDeleted) {
// when we remove an element, it results in a visible change
return true;
}
// check for any difference on a visible element
return Delta.isRightDifferent(element, partial);
}
// /**
// * Resolves conflicts for all previously added, removed and updated elements.
// * Updates the previous deltas with all the changes after conflict resolution.
// *
// * // CFDO: revisit since arrow seem often redrawn incorrectly
// *
// * @returns all elements affected by the conflict resolution
// */
// private resolveConflicts(
// prevElements: SceneElementsMap,
// nextElements: SceneElementsMap,
// ) {
// const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
// const updater = (
// element: ExcalidrawElement,
// updates: ElementUpdate<ExcalidrawElement>,
// ) => {
// const nextElement = nextElements.get(element.id); // only ever modify next element!
// if (!nextElement) {
// return;
// }
// let affectedElement: OrderedExcalidrawElement;
// if (prevElements.get(element.id) === nextElement) {
// // create the new element instance in case we didn't modify the element yet
// // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
// affectedElement = newElementWith(
// nextElement,
// updates as ElementUpdate<OrderedExcalidrawElement>,
// );
// } else {
// affectedElement = mutateElement(
// nextElement,
// updates as ElementUpdate<OrderedExcalidrawElement>,
// );
// }
// nextAffectedElements.set(affectedElement.id, affectedElement);
// nextElements.set(affectedElement.id, affectedElement);
// };
// // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
// for (const id of Object.keys(this.removed)) {
// ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
// }
// // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
// for (const id of Object.keys(this.added)) {
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
// }
// // updated delta is affecting the binding only in case it contains changed binding or bindable property
// for (const [id] of Array.from(Object.entries(this.updated)).filter(
// ([_, delta]) =>
// Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
// bindingProperties.has(prop as BindingProp | BindableProp),
// ),
// )) {
// const updatedElement = nextElements.get(id);
// if (!updatedElement || updatedElement.isDeleted) {
// // skip fixing bindings for updates on deleted elements
// continue;
// }
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
// }
// // filter only previous elements, which were now affected
// const prevAffectedElements = new Map(
// Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
// );
// // calculate complete deltas for affected elements, and assign them back to all the deltas
// // technically we could do better here if perf. would become an issue
// const { added, removed, updated } = ElementsDelta.calculate(
// prevAffectedElements,
// nextAffectedElements,
// );
// for (const [id, delta] of Object.entries(added)) {
// this.added[id] = delta;
// }
// for (const [id, delta] of Object.entries(removed)) {
// this.removed[id] = delta;
// }
// for (const [id, delta] of Object.entries(updated)) {
// this.updated[id] = delta;
// }
// return nextAffectedElements;
// }
// /**
// * Non deleted affected elements of removed elements (before and after applying delta),
// * should be unbound ~ bindings should not point from non deleted into the deleted element/s.
// */
// private static unbindAffected(
// prevElements: SceneElementsMap,
// nextElements: SceneElementsMap,
// id: string,
// updater: (
// element: ExcalidrawElement,
// updates: ElementUpdate<ExcalidrawElement>,
// ) => void,
// ) {
// // the instance could have been updated, so make sure we are passing the latest element to each function below
// const prevElement = () => prevElements.get(id); // element before removal
// const nextElement = () => nextElements.get(id); // element after removal
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
// BoundElement.unbindAffected(nextElements, nextElement(), updater);
// BindableElement.unbindAffected(nextElements, prevElement(), updater);
// BindableElement.unbindAffected(nextElements, nextElement(), updater);
// }
// /**
// * Non deleted affected elements of added or updated element/s (before and after applying delta),
// * should be rebound (if possible) with the current element ~ bindings should be bidirectional.
// */
// private static rebindAffected(
// prevElements: SceneElementsMap,
// nextElements: SceneElementsMap,
// id: string,
// updater: (
// element: ExcalidrawElement,
// updates: ElementUpdate<ExcalidrawElement>,
// ) => void,
// ) {
// // the instance could have been updated, so make sure we are passing the latest element to each function below
// const prevElement = () => prevElements.get(id); // element before addition / update
// const nextElement = () => nextElements.get(id); // element after addition / update
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
// BoundElement.rebindAffected(nextElements, nextElement(), updater);
// BindableElement.unbindAffected(
// nextElements,
// prevElement(),
// (element, updates) => {
// // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal)
// // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition
// if (isTextElement(element)) {
// updater(element, updates);
// }
// },
// );
// BindableElement.rebindAffected(nextElements, nextElement(), updater);
// }
// private static redrawTextBoundingBoxes(
// elements: SceneElementsMap,
// changed: Map<string, OrderedExcalidrawElement>,
// ) {
// const boxesToRedraw = new Map<
// string,
// { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
// >();
// for (const element of changed.values()) {
// if (isBoundToContainer(element)) {
// const { containerId } = element as ExcalidrawTextElement;
// const container = containerId ? elements.get(containerId) : undefined;
// if (container) {
// boxesToRedraw.set(container.id, {
// container,
// boundText: element as ExcalidrawTextElement,
// });
// }
// }
// if (hasBoundTextElement(element)) {
// const boundTextElementId = getBoundTextElementId(element);
// const boundText = boundTextElementId
// ? elements.get(boundTextElementId)
// : undefined;
// if (boundText) {
// boxesToRedraw.set(element.id, {
// container: element,
// boundText: boundText as ExcalidrawTextElement,
// });
// }
// }
// }
// for (const { container, boundText } of boxesToRedraw.values()) {
// if (container.isDeleted || boundText.isDeleted) {
// // skip redraw if one of them is deleted, as it would not result in a meaningful redraw
// continue;
// }
// redrawTextBoundingBox(boundText, container, elements, false);
// }
// }
// private static redrawBoundArrows(
// elements: SceneElementsMap,
// changed: Map<string, OrderedExcalidrawElement>,
// ) {
// for (const element of changed.values()) {
// if (!element.isDeleted && isBindableElement(element)) {
// updateBoundElements(element, elements, {
// changedElements: changed,
// });
// }
// }
// }
// private static reorderElements(
// elements: SceneElementsMap,
// changed: Map<string, OrderedExcalidrawElement>,
// flags: {
// containsVisibleDifference: boolean;
// containsZindexDifference: boolean;
// },
// ) {
// if (!flags.containsZindexDifference) {
// return elements;
// }
// const unordered = Array.from(elements.values());
// const ordered = orderByFractionalIndex([...unordered]);
// const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
// (acc, arrayIndex) => {
// const candidate = unordered[Number(arrayIndex)];
// if (candidate && changed.has(candidate.id)) {
// acc.set(candidate.id, candidate);
// }
// return acc;
// },
// new Map(),
// );
// if (!flags.containsVisibleDifference && moved.size) {
// // we found a difference in order!
// flags.containsVisibleDifference = true;
// }
// // synchronize all elements that were actually moved
// // could fallback to synchronizing all invalid indices
// return elementsToMap(syncMovedIndices(ordered, moved)) as typeof elements;
// }
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess(
deleted: ElementPartial,
inserted: ElementPartial,
): [ElementPartial, ElementPartial] {
try {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
} catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements delta.`);
if (shouldThrow()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
}

View File

@ -0,0 +1,26 @@
export type {
AppState,
ObservedElementsAppState,
ObservedStandaloneAppState,
ObservedAppState,
} from "@excalidraw/excalidraw/dist/excalidraw/types";
export type {
DTO,
SubtypeOf,
ValueOf,
} from "@excalidraw/excalidraw/dist/excalidraw/utility-types";
export type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
ElementsMap,
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
export type { ElementUpdate } from "@excalidraw/excalidraw/dist/excalidraw/element/mutateElement";
export type {
BindableProp,
BindingProp,
} from "@excalidraw/excalidraw/dist/excalidraw/element/binding";

View File

@ -0,0 +1,5 @@
export type { DeltaContainer } from "./common/interfaces";
export { Delta } from "./common/delta";
export { ElementsDelta } from "./containers/elements";
export { AppStateDelta } from "./containers/appstate";

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"strict": true,
"outDir": "dist/types",
"skipLibCheck": true,
"declaration": true,
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Node",
},
"exclude": [
"**/*.test.*",
"**/tests/*",
"types",
"dist",
],
}

View File

@ -0,0 +1,3 @@
{
"extends": ["../eslintrc.base.json"]
}

View File

@ -0,0 +1,19 @@
# @excalidraw/element
## Install
```bash
npm install @excalidraw/element
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/element
```
With PNPM, similarly install the package with this command:
```bash
pnpm add @excalidraw/element
```

3
packages/element/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@ -0,0 +1,56 @@
{
"name": "@excalidraw/element",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/element/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./../element/dist/types/element/src/*.d.ts"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw elements-related logic",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -1,4 +1,31 @@
import throttle from "lodash.throttle";
import {
randomInteger,
arrayToMap,
toBrandedType,
isDevEnv,
isTestEnv,
isReadonlyArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import {
orderByFractionalIndex,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
import { getSelectedElements } from "@excalidraw/element/selection";
import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@ -9,26 +36,15 @@ import type {
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
Ordered,
} from "../element/types";
import { isNonDeletedElement } from "../element";
import type { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameLikeElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection";
import type { AppState } from "../types";
import type { 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";
import { getElementsInGroup } from "../groups";
} from "@excalidraw/element/types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
import type {
Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../../excalidraw/types";
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
@ -54,14 +70,10 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
const validateIndicesThrottled = throttle(
(elements: readonly ExcalidrawElement[]) => {
if (
import.meta.env.DEV ||
import.meta.env.MODE === ENV.TEST ||
window?.DEBUG_FRACTIONAL_INDICES
) {
if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
validateFractionalIndices(elements, {
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
shouldThrow: isDevEnv() || isTestEnv(),
includeBoundTextValidation: true,
});
}
@ -97,44 +109,7 @@ const hashSelectionOpts = (
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") {
return true;
}
return false;
};
class Scene {
// ---------------------------------------------------------------------------
// static methods/props
// ---------------------------------------------------------------------------
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
// 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 mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
}
return this.sceneMapByElement.get(elementKey) || null;
}
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
@ -193,6 +168,12 @@ class Scene {
return this.frames;
}
constructor(elements: ElementsMapOrArray | null = null) {
if (elements) {
this.replaceAllElements(elements);
}
}
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
@ -287,23 +268,25 @@ class Scene {
}
replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements =
// ts doesn't like `Array.isArray` of `instanceof Map`
nextElements instanceof Array
? nextElements
: Array.from(nextElements.values());
// ts doesn't like `Array.isArray` of `instanceof Map`
if (!isReadonlyArray(nextElements)) {
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
validateIndicesThrottled(nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elements = syncInvalidIndices(nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {
if (isFrameLikeElement(element)) {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
});
const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements;
@ -348,12 +331,6 @@ class Scene {
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires
// (I guess?)
this.callbacks.clear();
@ -450,6 +427,42 @@ class Scene {
// then, check if the id is a group
return getElementsInGroup(elementsMap, id);
};
// Mutate an element with passed updates and trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation: boolean;
isDragging: boolean;
} = {
informMutation: true,
isDragging: false,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
const { version: prevVersion } = element;
const { version: nextVersion } = mutateElement(
element,
elementsMap,
updates,
options,
);
if (
// skip if the element is not in the scene (i.e. selection)
this.elementsMap.has(element.id) &&
// skip if the element's version hasn't changed, as mutateElement returned the same element
prevVersion !== nextVersion &&
options.informMutation
) {
this.triggerUpdate();
}
return element;
}
}
export default Scene;

View File

@ -1,31 +1,38 @@
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import { getDiamondPoints, getArrowheadPoints } from "../element";
import type { ElementShapes } from "./types";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
import {
isElbowArrow,
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
isLinearElement,
} from "../element/typeChecks";
} from "./typeChecks";
import { getCornerRadius, isPathALoop } from "./shapes";
import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
import { getCornerRadius, isPathALoop } from "../shapes";
import { headingForPointIsHorizontal } from "../element/heading";
import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -508,7 +515,10 @@ export const _generateElementShape = (
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(element.points, 0.75);
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
shape = generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element),
stroke: "none",

View File

@ -1,14 +1,23 @@
import type { Drawable } from "roughjs/bin/core";
import { RoughGenerator } from "roughjs/bin/generator";
import { COLOR_PALETTE } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawSelectionElement,
} from "../element/types";
import { elementWithCanvasCache } from "../renderer/renderElement";
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { _generateElementShape } from "./Shape";
import type { ElementShape, ElementShapes } from "./types";
import { COLOR_PALETTE } from "../colors";
import type { AppState, EmbedsValidationStatus } from "../types";
import { elementWithCanvasCache } from "./renderElement";
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
import type { Drawable } from "roughjs/bin/core";
export class ShapeCache {
private static rg = new RoughGenerator();

View File

@ -1,10 +1,11 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { mutateElement } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types";
export interface Alignment {
position: "start" | "center" | "end";
@ -13,10 +14,10 @@ export interface Alignment {
export const alignElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements,
elementsMap,
@ -31,12 +32,13 @@ export const alignElements = (
);
return group.map((element) => {
// update element
const updatedEle = mutateElement(element, {
const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
// update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedEle;

View File

@ -1,63 +1,14 @@
import type {
ExcalidrawBindableElement,
ExcalidrawElement,
NonDeleted,
ExcalidrawLinearElement,
PointBinding,
NonDeletedExcalidrawElement,
ElementsMap,
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
FixedPointBinding,
} from "./types";
import type { Bounds } from "./bounds";
import {
getCenterForBounds,
getElementBounds,
doBoundsIntersect,
} from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "@excalidraw/utils/collision";
import {
isArrowElement,
isBindableElement,
isBindingElement,
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
isFrameLikeElement,
isLinearElement,
isRectanguloidElement,
isTextElement,
} from "./typeChecks";
import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import type Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import {
KEYS,
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
} from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import {
compareHeading,
HEADING_DOWN,
HEADING_RIGHT,
HEADING_UP,
headingForPointFromElement,
vectorToHeading,
type Heading,
} from "./heading";
import type { LocalPoint, Radians } from "@excalidraw/math";
invariant,
isDevEnv,
isTestEnv,
elementCenterPoint,
} from "@excalidraw/common";
import {
lineSegment,
pointFrom,
@ -73,11 +24,68 @@ import {
vectorCross,
pointsEqual,
lineSegmentIntersectionPoints,
round,
PRECISION,
} from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import {
getCenterForBounds,
getElementBounds,
doBoundsIntersect,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance";
import {
headingForPointFromElement,
headingIsHorizontal,
vectorToHeading,
type Heading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
isBindableElement,
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
isFrameLikeElement,
isLinearElement,
isRectanguloidElement,
isTextElement,
} from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
import type {
ExcalidrawBindableElement,
ExcalidrawElement,
NonDeleted,
ExcalidrawLinearElement,
PointBinding,
NonDeletedExcalidrawElement,
ElementsMap,
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
FixedPointBinding,
} from "./types";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@ -121,7 +129,6 @@ export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep",
endBindingElement: ExcalidrawBindableElement | null | "keep",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@ -133,7 +140,7 @@ export const bindOrUnbindLinearElement = (
"start",
boundToElementIds,
unboundFromElementIds,
elementsMap,
scene,
);
bindOrUnbindLinearElementEdge(
linearElement,
@ -142,7 +149,7 @@ export const bindOrUnbindLinearElement = (
"end",
boundToElementIds,
unboundFromElementIds,
elementsMap,
scene,
);
const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -150,7 +157,7 @@ export const bindOrUnbindLinearElement = (
);
getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
mutateElement(element, {
scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
@ -168,7 +175,7 @@ const bindOrUnbindLinearElementEdge = (
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
// Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") {
@ -177,7 +184,7 @@ const bindOrUnbindLinearElementEdge = (
// null means break the bind, so nothing to consider here
if (bindableElement === null) {
const unbound = unbindLinearElement(linearElement, startOrEnd);
const unbound = unbindLinearElement(linearElement, startOrEnd, scene);
if (unbound != null) {
unboundFromElementIds.add(unbound);
}
@ -200,16 +207,11 @@ const bindOrUnbindLinearElementEdge = (
: startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id)
) {
bindLinearElement(
linearElement,
bindableElement,
startOrEnd,
elementsMap,
);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
} else {
bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
};
@ -353,11 +355,9 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
scene: Scene,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
@ -367,20 +367,20 @@ export const bindOrUnbindLinearElements = (
selectedElement,
isBindingEnabled,
draggingPoints ?? [],
elementsMap,
elements,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
selectedElement,
elementsMap,
elements,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
bindOrUnbindLinearElement(selectedElement, start, end, scene);
});
};
@ -420,15 +420,17 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
pointerCoords: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
): void => {
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
if (appState.startBoundElement != null) {
bindLinearElement(
linearElement,
appState.startBoundElement,
"start",
elementsMap,
scene,
);
}
@ -449,7 +451,7 @@ export const maybeBindLinearElement = (
"end",
)
) {
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
bindLinearElement(linearElement, hoveredElement, "end", scene);
}
}
};
@ -478,45 +480,43 @@ export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
if (!isArrowElement(linearElement)) {
return;
}
const binding: PointBinding | FixedPointBinding = {
let binding: PointBinding | FixedPointBinding = {
elementId: hoveredElement.id,
...(isElbowArrow(linearElement)
? {
...calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
focus: 0,
gap: 0,
}
: {
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
),
}),
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
scene.getNonDeletedElementsMap(),
),
hoveredElement,
),
};
mutateElement(linearElement, {
if (isElbowArrow(linearElement)) {
binding = {
...binding,
...calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
),
};
}
scene.mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) {
mutateElement(hoveredElement, {
scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id,
type: "arrow",
@ -558,13 +558,14 @@ const isLinearElementSimple = (
const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
scene: Scene,
): ExcalidrawBindableElement["id"] | null => {
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
const binding = linearElement[field];
if (binding == null) {
return null;
}
mutateElement(linearElement, { [field]: null });
scene.mutateElement(linearElement, { [field]: null });
return binding.elementId;
};
@ -732,7 +733,7 @@ const calculateFocusAndGap = (
// in explicitly.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
@ -748,6 +749,8 @@ export const updateBoundElements = (
return;
}
const elementsMap = scene.getNonDeletedElementsMap();
boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) {
return;
@ -788,7 +791,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings, true);
scene.mutateElement(element, bindings);
return;
}
@ -835,7 +838,7 @@ export const updateBoundElements = (
}> => update !== null,
);
LinearElementEditor.movePoints(element, updates, {
LinearElementEditor.movePoints(element, scene, updates, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
@ -846,7 +849,7 @@ export const updateBoundElements = (
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, elementsMap, false);
handleBindTextResize(element, scene, false);
}
});
};
@ -872,7 +875,6 @@ export const getHeadingForElbowArrowSnap = (
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
@ -882,22 +884,11 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading;
}
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
if (!distance) {
return vectorToHeading(
vectorFromPoint(
p,
pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
),
),
vectorFromPoint(p, elementCenterPoint(bindableElement)),
);
}
@ -907,7 +898,6 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
@ -923,113 +913,111 @@ const getDistanceForBinding = (
export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement | undefined,
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement);
if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
}
const aabb = aabbForElement(bindableElement);
const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>(
arrow.x + localP[0],
arrow.y + localP[1],
);
const p = isRectanguloidElement(bindableElement)
const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP)
: globalP;
const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb);
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x + arrow.points[adjacentPointIdx][0],
arrow.y + arrow.points[adjacentPointIdx][1],
),
center,
arrow.angle ?? 0,
);
if (bindableElement && aabb) {
const center = getCenterForBounds(aabb);
const intersection = intersectElementWithLineSegment(
let intersection: GlobalPoint | null = null;
if (elbowed) {
const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP),
);
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0],
!isHorizontal ? center[1] : edgePoint[1],
);
intersection = intersectElementWithLineSegment(
bindableElement,
lineSegment(
center,
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(p, center)),
Math.max(bindableElement.width, bindableElement.height),
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
center,
otherPoint,
),
),
)[0];
const currentDistance = pointDistance(p, center);
const fullDistance = Math.max(
pointDistance(intersection ?? p, center),
PRECISION,
);
const ratio = round(currentDistance / fullDistance, 6);
switch (true) {
case ratio > 0.9:
if (
currentDistance - fullDistance > FIXED_BINDING_DISTANCE ||
// Too close to determine vector from intersection to p
pointDistanceSq(p, intersection) < PRECISION
) {
return p;
}
return pointFromVector(
} else {
intersection = intersectElementWithLineSegment(
bindableElement,
lineSegment(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(p, intersection ?? center)),
ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE,
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) * 2,
),
intersection ?? center,
);
default:
return headingToMidBindPoint(p, bindableElement, aabb);
}
adjacentPoint,
),
),
FIXED_BINDING_DISTANCE,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
)[0];
}
return p;
};
const headingToMidBindPoint = (
p: GlobalPoint,
bindableElement: ExcalidrawBindableElement,
aabb: Bounds,
): GlobalPoint => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(vectorFromPoint(p, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return pointRotateRads(
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return pointRotateRads(
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return pointRotateRads(
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center,
bindableElement.angle,
);
default:
return pointRotateRads(
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center,
bindableElement.angle,
);
if (
!intersection ||
// Too close to determine vector from intersection to edgePoint
pointDistanceSq(edgePoint, intersection) < PRECISION
) {
return edgePoint;
}
if (elbowed) {
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
};
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: GlobalPoint,
): GlobalPoint => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@ -1126,10 +1114,9 @@ export const snapToMid = (
tolerance: number = 0.05,
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = pointFrom<GlobalPoint>(
x + width / 2 - 0.1,
y + height / 2 - 0.1,
);
const center = elementCenterPoint(element, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
@ -1212,12 +1199,8 @@ const updateBoundPoint = (
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const globalMidPoint = elementCenterPoint(bindableElement);
const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
@ -1261,47 +1244,40 @@ const updateBoundPoint = (
elementsMap,
);
const center = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const center = elementCenterPoint(bindableElement);
const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) +
Math.max(bindableElement.width, bindableElement.height) * 2;
const intersections = intersectElementWithLineSegment(
bindableElement,
lineSegment<GlobalPoint>(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
interceptorLength,
),
const intersections = [
...intersectElementWithLineSegment(
bindableElement,
lineSegment<GlobalPoint>(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(
vectorFromPoint(focusPointAbsolute, adjacentPoint),
),
interceptorLength,
),
adjacentPoint,
),
),
binding.gap,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
),
binding.gap,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
);
// debugClear();
// debugDrawPoint(intersections[0], { color: "red", permanent: true });
// debugDrawLine(
// lineSegment<GlobalPoint>(
// adjacentPoint,
// pointFromVector(
// vectorScale(
// vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
// interceptorLength,
// ),
// adjacentPoint,
// ),
// ),
// { permanent: true, color: "green" },
// );
// Fallback when arrow doesn't point to the shape
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
pointDistance(adjacentPoint, edgePointAbsolute),
),
adjacentPoint,
),
];
if (intersections.length > 1) {
// The adjacent point is outside the shape (+ gap)
@ -1326,7 +1302,6 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
@ -1413,107 +1388,76 @@ const getLinearElementEdgeCoors = (
);
};
// We need to:
// 1: Update elements not selected to point to duplicated elements
// 2: Update duplicated elements to point to other duplicated elements
export const fixBindingsAfterDuplication = (
sceneElements: readonly ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
// Only when alt-dragging the new "duplicates" act as the "old", while
// the "old" elements act as the "new copy" - essentially working reverse
// to the other two.
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
): void => {
// First collect all the binding/bindable elements, so we only update
// each once, regardless of whether they were duplicated or not.
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
const duplicateIdToOldId = new Map(
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
);
oldElements.forEach((oldElement) => {
const { boundElements } = oldElement;
if (boundElements != null && boundElements.length > 0) {
boundElements.forEach((boundElement) => {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
allBoundElementIds.add(boundElement.id);
}
export const fixDuplicatedBindingsAfterDuplication = (
duplicatedElements: ExcalidrawElement[],
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
duplicateElementsMap: NonDeletedSceneElementsMap,
) => {
for (const duplicateElement of duplicatedElements) {
if ("boundElements" in duplicateElement && duplicateElement.boundElements) {
Object.assign(duplicateElement, {
boundElements: duplicateElement.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = origIdToDuplicateId.get(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
),
});
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
if (isBindingElement(oldElement)) {
if (oldElement.startBinding != null) {
const { elementId } = oldElement.startBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.endBinding != null) {
const { elementId } = oldElement.endBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.startBinding != null || oldElement.endBinding != null) {
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
if ("containerId" in duplicateElement && duplicateElement.containerId) {
Object.assign(duplicateElement, {
containerId:
origIdToDuplicateId.get(duplicateElement.containerId) ?? null,
});
}
});
// Update the linear elements
(
sceneElements.filter(({ id }) =>
allBoundElementIds.has(id),
) as ExcalidrawLinearElement[]
).forEach((element) => {
const { startBinding, endBinding } = element;
mutateElement(element, {
startBinding: newBindingAfterDuplication(
startBinding,
oldIdToDuplicatedId,
),
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
});
});
if ("endBinding" in duplicateElement && duplicateElement.endBinding) {
const newEndBindingId = origIdToDuplicateId.get(
duplicateElement.endBinding.elementId,
);
Object.assign(duplicateElement, {
endBinding: newEndBindingId
? {
...duplicateElement.endBinding,
elementId: newEndBindingId,
}
: null,
});
}
if ("startBinding" in duplicateElement && duplicateElement.startBinding) {
const newEndBindingId = origIdToDuplicateId.get(
duplicateElement.startBinding.elementId,
);
Object.assign(duplicateElement, {
startBinding: newEndBindingId
? {
...duplicateElement.startBinding,
elementId: newEndBindingId,
}
: null,
});
}
// Update the bindable shapes
sceneElements
.filter(({ id }) => allBindableElementIds.has(id))
.forEach((bindableElement) => {
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
const boundElements = sceneElements.find(
({ id }) => id === oldElementId,
)?.boundElements;
if (boundElements && boundElements.length > 0) {
mutateElement(bindableElement, {
boundElements: boundElements.map((boundElement) =>
oldIdToDuplicatedId.has(boundElement.id)
? {
id: oldIdToDuplicatedId.get(boundElement.id)!,
type: boundElement.type,
}
: boundElement,
),
});
}
});
};
const newBindingAfterDuplication = (
binding: PointBinding | null,
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
): PointBinding | null => {
if (binding == null) {
return null;
if (isElbowArrow(duplicateElement)) {
Object.assign(
duplicateElement,
updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
points: [
duplicateElement.points[0],
duplicateElement.points[duplicateElement.points.length - 1],
],
}),
);
}
}
return {
...binding,
elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
};
};
export const fixBindingsAfterDeletion = (
@ -1523,8 +1467,12 @@ export const fixBindingsAfterDeletion = (
const elements = arrayToMap(sceneElements);
for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, mutateElement);
BindableElement.unbindAffected(elements, element, mutateElement);
BoundElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
BindableElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
}
};
@ -1603,10 +1551,7 @@ const determineFocusDistance = (
// Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint,
): number => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
if (pointsEqual(a, b)) {
return 0;
@ -1724,21 +1669,6 @@ const determineFocusDistance = (
)
.sort((g, h) => Math.abs(g) - Math.abs(h));
// debugClear();
// [
// lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]),
// lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]),
// ]
// .filter((p): p is GlobalPoint => p !== null)
// .forEach((p) => debugDrawPoint(p, { color: "black", permanent: true }));
// debugDrawPoint(determineFocusPoint(element, ordered[0] ?? 0, rotatedA), {
// color: "red",
// permanent: true,
// });
// debugDrawLine(rotatedInterceptor, { color: "green", permanent: true });
// debugDrawLine(interceptees[0], { color: "red", permanent: true });
// debugDrawLine(interceptees[1], { color: "red", permanent: true });
const signedDistanceRatio = ordered[0] ?? 0;
return signedDistanceRatio;
@ -1751,10 +1681,7 @@ const determineFocusPoint = (
focus: number,
adjacentPoint: GlobalPoint,
): GlobalPoint => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
if (focus === 0) {
return center;
@ -2185,10 +2112,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
),
elementCenterPoint(element),
element.angle,
);
};

View File

@ -1,36 +1,12 @@
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
} from "./types";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core";
import type { AppState } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { ShapeCache } from "../scene/ShapeCache";
import { arrayToMap, invariant } from "../utils";
import type {
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "@excalidraw/math";
rescalePoints,
arrayToMap,
invariant,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
@ -39,8 +15,57 @@ import {
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
import type { Mutable } from "../utility-types";
import { getCurvePathOps } from "@excalidraw/utils/geometry/shape";
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
Curve,
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
export type RectangleBox = {
x: number;
@ -247,50 +272,82 @@ export const getElementAbsoluteCoords = (
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
/**
* Given an element, return the line segments that make up the element.
*
* Uses helpers from /math
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center = pointFrom<GlobalPoint>(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = [];
if (shape.type === "polycurve") {
const curves = shape.data;
const points = curves
.map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0;
while (i < element.points.length - 1) {
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads(
pointFrom(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
return segments;
} else if (shape.type === "polyline") {
return shape.data as LineSegment<GlobalPoint>[];
} else if (_isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (element.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(element);
const cornerSegments = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (shape.type === "polygon") {
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (container && isLinearElement(container)) {
const segments: LineSegment<GlobalPoint>[] = [
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
];
return segments;
}
}
const points = shape.data as GlobalPoint[];
const segments: LineSegment<GlobalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
return segments;
} else if (shape.type === "ellipse") {
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
}
const [nw, ne, sw, se, n, s, w, e] = (
const [nw, ne, sw, se, , , w, e] = (
[
[x1, y1],
[x2, y1],
@ -303,28 +360,6 @@ export const getElementLineSegments = (
] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [
lineSegment(nw, ne),
lineSegment(sw, se),
@ -337,6 +372,94 @@ export const getElementLineSegments = (
];
};
const _isRectanguloidElement = (
element: ExcalidrawElement,
): element is ExcalidrawRectanguloidElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
const getRotatedSides = (
sides: LineSegment<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
const points: GlobalPoint[] = [];
const n = 90;
const deltaT = (Math.PI * 2) / n;
for (let i = 0; i < n; i++) {
const t = i * deltaT;
const x = center[0] + a * Math.cos(t);
const y = center[1] + b * Math.sin(t);
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
}
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
segments.push(lineSegment(points[points.length - 1], points[0]));
return segments;
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*
@ -821,10 +944,10 @@ export const getElementBounds = (
};
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
elements: ElementsMapOrArray,
elementsMap?: ElementsMap,
): Bounds => {
if (!elements.length) {
if (!sizeOf(elements)) {
return [0, 0, 0, 0];
}

View File

@ -1,31 +1,4 @@
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
import type { GeometricShape } from "@excalidraw/utils/geometry/shape";
import { getPolygonShape } from "@excalidraw/utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { isTransparent } from "../utils";
import {
hasBoundTextElement,
isIframeLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
@ -36,15 +9,47 @@ import {
pointRotateRads,
pointsEqual,
} from "@excalidraw/math";
import {
ellipse,
ellipseLineIntersectionPoints,
} from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds";
import {
hasBoundTextElement,
isIframeLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
return false;
@ -184,10 +189,7 @@ const intersectRectanguloidWithLineSegment = (
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>(
@ -205,28 +207,28 @@ const intersectRectanguloidWithLineSegment = (
const [sides, corners] = deconstructRectanguloidElement(element, offset);
return (
[
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space
...sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((x) => x != null)
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)),
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space
sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((x) => x != null)
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
// Test intersection against the corners which are cubic bezier curves,
// keep only the valid intersection points and rotate them back to scene
// space
...corners
.flatMap((t) =>
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
)
.filter((i) => i != null)
.map((j) => pointRotateRads(j, center, element.angle)),
]
.concat(
corners
.flatMap((t) =>
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
)
.filter((i) => i != null)
.map((j) => pointRotateRads(j, center, element.angle)),
)
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
@ -246,10 +248,7 @@ const intersectDiamondWithLineSegment = (
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
@ -259,25 +258,25 @@ const intersectDiamondWithLineSegment = (
const [sides, curves] = deconstructDiamondElement(element, offset);
return (
[
...sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((p): p is GlobalPoint => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)),
...curves
.flatMap((p) =>
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
)
.filter((p) => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads(p, center, element.angle)),
]
sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((p): p is GlobalPoint => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
.concat(
curves
.flatMap((p) =>
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
)
.filter((p) => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads(p, center, element.angle)),
)
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
@ -297,10 +296,7 @@ const intersectEllipseWithLineSegment = (
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);

View File

@ -1,4 +1,4 @@
import type { ElementOrToolType } from "../types";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" ||

View File

@ -1,4 +1,3 @@
import { type Point } from "points-on-curve";
import {
type Radians,
pointFrom,
@ -13,6 +12,15 @@ import {
clamp,
isCloseTo,
} from "@excalidraw/math";
import { type Point } from "points-on-curve";
import { elementCenterPoint } from "@excalidraw/common";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
import type { TransformHandleType } from "./transformHandles";
import type {
ElementsMap,
@ -21,10 +29,6 @@ import type {
ImageCrop,
NonDeleted,
} from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
export const MINIMAL_CROP_SIZE = 10;
@ -59,7 +63,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
elementCenterPoint(element),
-element.angle as Radians,
);

View File

@ -1,21 +1,26 @@
import type { GlobalPoint, Radians } from "@excalidraw/math";
import {
curvePointDistance,
distanceToLineSegment,
pointFrom,
pointRotateRads,
} from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import { elementCenterPoint } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
ExcalidrawRectanguloidElement,
} from "./types";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
@ -49,10 +54,7 @@ const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
) => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@ -80,10 +82,7 @@ const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
p: GlobalPoint,
): number => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
@ -111,10 +110,7 @@ const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
p: GlobalPoint,
): number => {
const center = pointFrom(
element.x + element.width / 2,
element.y + element.height / 2,
);
const center = elementCenterPoint(element);
return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians),

View File

@ -1,7 +1,9 @@
import { newElementWith } from "./element/mutateElement";
import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getMaximumGroups } from "./groups";
import { getCommonBoundingBox } from "./element/bounds";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution {
space: "between";

View File

@ -1,17 +1,23 @@
import { updateBoundElements } from "./binding";
import type { Bounds } from "./bounds";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types";
import {
TEXT_AUTOWRAP_THRESHOLD,
getGridPoint,
getFontString,
} from "@excalidraw/common";
import type {
AppState,
NormalizedZoomValue,
NullableGridSize,
PointerDownState,
} from "../types";
} from "@excalidraw/excalidraw/types";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
import type Scene from "../scene/Scene";
import { getMinTextElementWidth } from "./textMeasurements";
import {
isArrowElement,
isElbowArrow,
@ -19,10 +25,11 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
import { getMinTextElementWidth } from "./textMeasurements";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@ -76,20 +83,27 @@ export const dragSelectedElements = (
}
}
const commonBounds = getCommonBounds(
Array.from(elementsToUpdate).map(
(el) => pointerDownState.originalElements.get(el.id) ?? el,
),
);
const origElements: ExcalidrawElement[] = [];
for (const element of elementsToUpdate) {
const origElement = pointerDownState.originalElements.get(element.id);
// if original element is not set (e.g. when you duplicate during a drag
// operation), exit to avoid undefined behavior
if (!origElement) {
return;
}
origElements.push(origElement);
}
const adjustedOffset = calculateOffset(
commonBounds,
getCommonBounds(origElements),
offset,
snapOffset,
gridSize,
);
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset);
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
@ -97,9 +111,14 @@ export const dragSelectedElements = (
scene.getNonDeletedElementsMap(),
);
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
updateElementCoords(
pointerDownState,
textElement,
scene,
adjustedOffset,
);
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}
@ -140,6 +159,7 @@ const calculateOffset = (
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number },
) => {
const originalElement =
@ -148,7 +168,7 @@ const updateElementCoords = (
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
mutateElement(element, {
scene.mutateElement(element, {
x: nextX,
y: nextY,
});
@ -175,6 +195,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio,
shouldResizeFromCenter,
zoom,
scene,
widthAspectRatio = null,
originOffset = null,
informMutation = true,
@ -190,6 +211,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue;
scene: Scene;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null;
@ -270,7 +292,7 @@ export const dragNewElement = ({
};
}
mutateElement(
scene.mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0),
@ -280,7 +302,7 @@ export const dragNewElement = ({
...textAutoResize,
...imageInitialDimension,
},
informMutation,
{ informMutation, isDragging: false },
);
}
};

View File

@ -0,0 +1,487 @@
import {
ORIG_ID,
randomId,
randomInteger,
arrayToMap,
castArray,
findLastIndex,
getUpdatedTimestamp,
isTestEnv,
} from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
getElementsInGroup,
getNewGroupIdsForDuplication,
getSelectedGroupForElement,
} from "./groups";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "./frame";
import { normalizeElementOrder } from "./sortElements";
import { bumpVersion } from "./mutateElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "./typeChecks";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
import type {
ElementsMap,
ExcalidrawElement,
GroupId,
NonDeletedSceneElementsMap,
} from "./types";
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
* introduction of gruoping/ungrouping elements.
* @param editingGroupId The current group being edited. The new
* element will inherit this group and its
* parents.
* @param groupIdMapForOperation A Map that maps old group IDs to
* duplicated ones. If you are duplicating
* multiple elements at once, share this map
* amongst all of them
* @param element Element to duplicate
*/
export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement,
randomizeSeed?: boolean,
): Readonly<TElement> => {
const copy = deepCopyElement(element);
if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = randomId();
copy.updated = getUpdatedTimestamp();
if (randomizeSeed) {
copy.seed = randomInteger();
bumpVersion(copy);
}
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, randomId());
}
return groupIdMapForOperation.get(groupId)!;
},
);
return copy;
};
export const duplicateElements = (
opts: {
elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean;
overrides?: (data: {
duplicateElement: ExcalidrawElement;
origElement: ExcalidrawElement;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
}) => Partial<ExcalidrawElement>;
} & (
| {
/**
* Duplicates all elements in array.
*
* Use this when programmaticaly duplicating elements, without direct
* user interaction.
*/
type: "everything";
}
| {
/**
* Duplicates specified elements and inserts them back into the array
* in specified order.
*
* Use this when duplicating Scene elements, during user interaction
* such as alt-drag or on duplicate action.
*/
type: "in-place";
idsOfElementsToDuplicate: Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
appState: {
editingGroupId: AppState["editingGroupId"];
selectedGroupIds: AppState["selectedGroupIds"];
};
}
),
) => {
let { elements } = opts;
const appState =
"appState" in opts
? opts.appState
: ({
editingGroupId: null,
selectedGroupIds: {},
} as const);
// Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const groupIdMap = new Map();
const duplicatedElements: ExcalidrawElement[] = [];
const origElements: ExcalidrawElement[] = [];
const origIdToDuplicateId = new Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>();
const duplicateIdToOrigElement = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate =
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
// For sanity
if (opts.type === "in-place") {
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
elements
.filter((el) => el.groupIds?.includes(groupId))
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
}
}
elements = normalizeElementOrder(elements);
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
// helper functions
// -------------------------------------------------------------------------
// Used for the heavy lifing of copying a single element, a group of elements
// an element with bound text etc.
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>(
element: T,
): T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
opts.randomizeSeed,
);
processedIds.set(newElement.id, true);
duplicateElementsMap.set(newElement.id, newElement);
origIdToDuplicateId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
origElements.push(element);
duplicatedElements.push(newElement);
acc.push(newElement);
return acc;
},
[],
);
return (
Array.isArray(element) ? _newElements : _newElements[0] || null
) as T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null;
};
// Helper to position cloned elements in the Z-order the product needs it
const insertBeforeOrAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
if (!elements) {
return;
}
if (index > elementsWithDuplicates.length - 1) {
elementsWithDuplicates.push(...castArray(elements));
return;
}
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
for (const element of elements) {
if (processedIds.has(element.id)) {
continue;
}
if (!_idsOfElementsToDuplicate.has(element.id)) {
continue;
}
// groups
// -------------------------------------------------------------------------
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.groupIds?.includes(groupId);
});
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
continue;
}
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertBeforeOrAfterIndex(
targetIndex,
copyElements([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
);
});
if (boundTextElement) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([element, boundTextElement]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.id === element.id || el.id === container?.id;
});
if (container) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([container, element]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
copyElements(element),
);
}
// ---------------------------------------------------------------------------
fixDuplicatedBindingsAfterDuplication(
duplicatedElements,
origIdToDuplicateId,
duplicateElementsMap as NonDeletedSceneElementsMap,
);
bindElementsToFramesAfterDuplication(
elementsWithDuplicates,
origElements,
origIdToDuplicateId,
);
if (opts.overrides) {
for (const duplicateElement of duplicatedElements) {
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
if (origElement) {
Object.assign(
duplicateElement,
opts.overrides({
duplicateElement,
origElement,
origIdToDuplicateId,
}),
);
}
}
}
return {
duplicatedElements,
duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
//
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
//
// Adapted from https://github.com/lukeed/klona
//
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") {
return val;
}
const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp =
typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val))
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
tmp[key] = _deepCopyElement(val[key], depth + 1);
}
}
return tmp;
}
if (Array.isArray(val)) {
let k = val.length;
const arr = new Array(k);
while (k--) {
arr[k] = _deepCopyElement(val[k], depth + 1);
}
return arr;
}
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (import.meta.env.DEV) {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val;
};
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};

View File

@ -12,11 +12,18 @@ import {
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points";
import { aabbForElement, pointInsideBounds } from "../shapes";
import { invariant, isAnyTrue, tupleToCoors } from "../utils";
import type { AppState } from "../types";
import {
BinaryHeap,
invariant,
isAnyTrue,
tupleToCoors,
getSizeFromPoints,
isDevEnv,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
bindPointToSnapToElementOutline,
FIXED_BINDING_DISTANCE,
@ -25,8 +32,7 @@ import {
snapToMid,
getHoveredElementForBinding,
} from "./binding";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import { distanceToBindableElement } from "./distance";
import {
compareHeading,
flipHeading,
@ -44,8 +50,12 @@ import { isBindableElement } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import type {
Arrowhead,
ElementsMap,
@ -54,7 +64,6 @@ import type {
FixedSegment,
NonDeletedExcalidrawElement,
} from "./types";
import { distanceToBindableElement } from "./distance";
type GridAddress = [number, number] & { _brand: "gridaddress" };
@ -235,16 +244,6 @@ const handleSegmentRenormalization = (
nextPoints.map((p) =>
pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
),
arrow.startBinding &&
getBindableElementForId(
arrow.startBinding.elementId,
elementsMap,
),
arrow.endBinding &&
getBindableElementForId(
arrow.endBinding.elementId,
elementsMap,
),
),
) ?? [],
),
@ -255,7 +254,7 @@ const handleSegmentRenormalization = (
);
}
import.meta.env.DEV &&
isDevEnv() &&
invariant(
validateElbowPoints(nextPoints),
"Invalid elbow points with fixed segments",
@ -338,9 +337,6 @@ const handleSegmentRelease = (
y,
),
],
startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap),
endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
{ isDragging: false },
);
@ -890,7 +886,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap,
updates: {
points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null;
fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
@ -980,6 +976,8 @@ export const updateElbowArrowPoints = (
);
}
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
const updatedPoints: readonly LocalPoint[] = updates.points
? updates.points && updates.points.length === 2
? arrow.points.map((p, idx) =>
@ -992,26 +990,36 @@ export const updateElbowArrowPoints = (
: updates.points.slice()
: arrow.points.slice();
// 0. During all element replacement in the scene, we just need to renormalize
// During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
const {
startBinding: updatedStartBinding,
endBinding: updatedEndBinding,
...restOfTheUpdates
} = updates;
const startBinding =
typeof updates.startBinding !== "undefined"
? updates.startBinding
typeof updatedStartBinding !== "undefined"
? updatedStartBinding
: arrow.startBinding;
const endBinding =
typeof updates.endBinding !== "undefined"
? updates.endBinding
typeof updatedEndBinding !== "undefined"
? updatedEndBinding
: arrow.endBinding;
const startElement =
startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap);
const endElement =
endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
if (
(elementsMap.size === 0 && validateElbowPoints(updatedPoints)) ||
startElement?.id !== startBinding?.elementId ||
endElement?.id !== endBinding?.elementId
(startBinding && !startElement && areUpdatedPointsValid) ||
(endBinding && !endElement && areUpdatedPointsValid) ||
(elementsMap.size === 0 && areUpdatedPointsValid) ||
(Object.keys(restOfTheUpdates).length === 0 &&
(startElement?.id !== startBinding?.elementId ||
endElement?.id !== endBinding?.elementId))
) {
return normalizeArrowElementUpdate(
updatedPoints.map((p) =>
@ -1043,12 +1051,22 @@ export const updateElbowArrowPoints = (
},
elementsMap,
updatedPoints,
startElement,
endElement,
options,
);
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
// 0. During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
if (elementsMap.size === 0 && areUpdatedPointsValid) {
return normalizeArrowElementUpdate(
updatedPoints.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
),
arrow.fixedSegments,
arrow.startIsSpecial,
arrow.endIsSpecial,
);
}
////
// 1. Renormalize the arrow
@ -1071,7 +1089,8 @@ export const updateElbowArrowPoints = (
p,
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
),
)
) &&
areUpdatedPointsValid
) {
return {};
}
@ -1182,8 +1201,6 @@ const getElbowArrowData = (
},
elementsMap: NonDeletedSceneElementsMap,
nextPoints: readonly LocalPoint[],
startElement: ExcalidrawBindableElement | null,
endElement: ExcalidrawBindableElement | null,
options?: {
isDragging?: boolean;
zoom?: AppState["zoom"];
@ -1198,8 +1215,8 @@ const getElbowArrowData = (
GlobalPoint
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
let hoveredStartElement = startElement;
let hoveredEndElement = endElement;
let hoveredStartElement = null;
let hoveredEndElement = null;
if (options?.isDragging) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
@ -1208,53 +1225,59 @@ const getElbowArrowData = (
elementsMap,
elements,
options?.zoom,
) || startElement;
) || null;
hoveredEndElement =
getHoveredElement(
origEndGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || endElement;
) || null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
null
: null;
hoveredEndElement = arrow.endBinding
? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null
: null;
}
const startGlobalPoint = getGlobalPoint(
{
...arrow,
type: "arrow",
elbowed: true,
points: nextPoints,
} as ExcalidrawElbowArrowElement,
"start",
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
startElement,
hoveredStartElement,
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
{
...arrow,
type: "arrow",
elbowed: true,
points: nextPoints,
} as ExcalidrawElbowArrowElement,
"end",
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
endElement,
hoveredEndElement,
options?.isDragging,
);
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
elementsMap,
hoveredStartElement,
origStartGlobalPoint,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
elementsMap,
hoveredEndElement,
origEndGlobalPoint,
);
@ -2186,36 +2209,35 @@ const getGlobalPoint = (
startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
element?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): GlobalPoint => {
if (isDragging) {
if (hoveredElement) {
if (element) {
const snapPoint = bindPointToSnapToElementOutline(
arrow,
hoveredElement,
element,
startOrEnd,
);
return snapToMid(hoveredElement, snapPoint);
return snapToMid(element, snapPoint);
}
return initialPoint;
}
if (boundElement) {
if (element) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0],
boundElement,
element,
);
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(boundElement, fixedGlobalPoint) -
distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
) > 0.01
? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd)
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint;
}
@ -2225,7 +2247,6 @@ const getGlobalPoint = (
const getBindPointHeading = (
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
): Heading =>
@ -2243,7 +2264,6 @@ const getBindPointHeading = (
number,
],
),
elementsMap,
origPoint,
);

View File

@ -2,10 +2,12 @@
* Create and link between shapes.
*/
import { ELEMENT_LINK_KEY } from "../constants";
import { normalizeLink } from "../data/url";
import { elementsAreInSameGroup } from "../groups";
import type { AppProps, AppState } from "../types";
import { ELEMENT_LINK_KEY, normalizeLink } from "@excalidraw/common";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { elementsAreInSameGroup } from "./groups";
import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude<

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