diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile
index fd5b38d1e8..ce8c857650 100644
--- a/.codesandbox/Dockerfile
+++ b/.codesandbox/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:18-bullseye
+FROM node:24-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
diff --git a/.env.development b/.env.development
index bf641c34c8..5e2d3dfe36 100644
--- a/.env.development
+++ b/.env.development
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
-VITE_APP_AI_BACKEND=http://localhost:3015
+VITE_APP_AI_BACKEND=http://localhost:3016
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
-VITE_APP_PORT=3000
+VITE_APP_PORT=3001
#Debug flags
diff --git a/.eslintrc.json b/.eslintrc.json
index 89f8227361..708210535c 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -39,5 +39,26 @@
"allowReferrer": true
}
]
- }
+ },
+ "overrides": [
+ {
+ "files": ["packages/excalidraw/**/*.{ts,tsx}"],
+ "excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
+ "rules": {
+ "@typescript-eslint/no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": ["@excalidraw/excalidraw"],
+ "message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
+ "allowTypeImports": true
+ }
+ ],
+ "paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
+ }
+ ]
+ }
+ }
+ ]
}
diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml
index 6e2c0d00e0..c365647ee8 100644
--- a/.github/workflows/autorelease-excalidraw.yml
+++ b/.github/workflows/autorelease-excalidraw.yml
@@ -12,10 +12,10 @@ jobs:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- - name: Setup Node.js 18.x
+ - name: Setup Node.js
uses: actions/setup-node@v2
with:
- node-version: 18.x
+ node-version: 20.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml
index e1ef216651..a572df9bed 100644
--- a/.github/workflows/cancel.yml
+++ b/.github/workflows/cancel.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- - uses: styfle/cancel-workflow-action@0.6.0
+ - uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 82f826361c..cc73980d10 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -9,10 +9,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- - name: Setup Node.js 18.x
+ - name: Setup Node.js
uses: actions/setup-node@v2
with:
- node-version: 18.x
+ node-version: 20.x
- name: Install and lint
run: |
diff --git a/.github/workflows/locales-coverage.yml b/.github/workflows/locales-coverage.yml
index 957e9bc37c..67a942438b 100644
--- a/.github/workflows/locales-coverage.yml
+++ b/.github/workflows/locales-coverage.yml
@@ -14,10 +14,10 @@ jobs:
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- - name: Setup Node.js 18.x
+ - name: Setup Node.js
uses: actions/setup-node@v2
with:
- node-version: 18.x
+ node-version: 20.x
- name: Create report file
run: |
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body
- name: Update description with coverage
- uses: kt3k/update-pr-description@v1.0.1
+ uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml
index 68eee27755..d0aedcb26b 100644
--- a/.github/workflows/publish-docker.yml
+++ b/.github/workflows/publish-docker.yml
@@ -13,16 +13,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Login to DockerHub
- uses: docker/login-action@v2
+ uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
push: true
diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml
index 34a6413fe2..8bfc1327b4 100644
--- a/.github/workflows/semantic-pr-title.yml
+++ b/.github/workflows/semantic-pr-title.yml
@@ -11,6 +11,6 @@ jobs:
semantic:
runs-on: ubuntu-latest
steps:
- - uses: amannn/action-semantic-pull-request@v5
+ - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/sentry-production.yml b/.github/workflows/sentry-production.yml
index cea4cf63d6..4434873fd3 100644
--- a/.github/workflows/sentry-production.yml
+++ b/.github/workflows/sentry-production.yml
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- - name: Setup Node.js 18.x
+ - name: Setup Node.js
uses: actions/setup-node@v2
with:
- node-version: 18.x
+ node-version: 20.x
- name: Install and build
run: |
yarn --frozen-lockfile
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE
- sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
+ sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli releases finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production
env:
diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml
index 5bd3c0d92b..2a24507630 100644
--- a/.github/workflows/size-limit.yml
+++ b/.github/workflows/size-limit.yml
@@ -11,16 +11,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
- - name: Setup Node.js 18.x
+ - name: Setup Node.js
uses: actions/setup-node@v3
with:
- node-version: 18.x
+ node-version: 20.x
- name: Install in packages/excalidraw
run: yarn
working-directory: packages/excalidraw
env:
CI: true
- - uses: andresz1/size-limit-action@v1
+ - uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm
diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml
index 7ff40ad5d2..0cb6327564 100644
--- a/.github/workflows/test-coverage-pr.yml
+++ b/.github/workflows/test-coverage-pr.yml
@@ -14,13 +14,13 @@ jobs:
- name: "Install Node"
uses: actions/setup-node@v2
with:
- node-version: "18.x"
+ node-version: "20.x"
- name: "Install Deps"
run: yarn install
- name: "Test Coverage"
run: yarn test:coverage
- name: "Report Coverage"
if: always() # Also generate the report if tests are failing
- uses: davelosert/vitest-coverage-report-action@v2
+ uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7d454ecfc4..8bebd6c1ee 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Setup Node.js 18.x
+ - name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: 18.x
+ node-version: 20.x
- name: Install and test
run: |
yarn install
diff --git a/.nvmrc b/.nvmrc
deleted file mode 100644
index 3c032078a4..0000000000
--- a/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-18
diff --git a/Dockerfile b/Dockerfile
index c08385d654..e15b425704 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM --platform=${BUILDPLATFORM} node:18 AS build
+FROM --platform=${BUILDPLATFORM} node:24 AS build
WORKDIR /opt/node_app
@@ -13,7 +13,7 @@ ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
-FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
+FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
diff --git a/README.md b/README.md
index f1cf030539..48b9d1de9f 100644
--- a/README.md
+++ b/README.md
@@ -23,23 +23,17 @@
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx
index e7852cee94..39e8e18425 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
```jsx live
function App() {
return (
-
+
{
- const device = useDevice();
- if (device.editor.isMobile) {
+ const editorInterface = useEditorInterface();
+ if (editorInterface.formFactor === "phone") {
return (
alert("This is custom footer in mobile menu")}
>
custom footer
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx
index b633236aad..e1dd071eac 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
- startArrowhead: "dot",
+ startArrowhead: "circle",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/utils/utils-intro.md b/dev-docs/docs/@excalidraw/excalidraw/api/utils/utils-intro.md
index 69bd88a838..ddbc7194d5 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/utils/utils-intro.md
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/utils/utils-intro.md
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords({ clientX: number, clientY: number },  
appState: AppState ): {x: number, y: number}
-### useDevice
+### useEditorInterface
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
```jsx live noInline
const MobileFooter = ({}) => {
- const device = useDevice();
- if (device.editor.isMobile) {
+ const editorInterface = useEditorInterface();
+ if (editorInterface.formFactor === "phone") {
return (
);
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
| Name | Type | Description |
-| --- | --- | --- |
-| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
-| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
-| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
-| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
-| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
+| ---- | ---- | ----------- |
+
+The `EditorInterface` object has the following properties:
+
+| Name | Type | Description |
+| --- | --- | --- | --- | --- | --- |
+| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
+| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
+| `userAgent.raw` | `string` | Raw user agent string |
+| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
+| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
+| `isTouchScreen` | `boolean` | True if touch events are detected |
+| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
+| `isLandscape` | `boolean` | True if viewport is in landscape mode |
### i18n
diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js
index 4e8d75800a..d2b34843ee 100644
--- a/dev-docs/docusaurus.config.js
+++ b/dev-docs/docusaurus.config.js
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
- label: "Twitter",
- href: "https://twitter.com/excalidraw",
+ label: "𝕏",
+ href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
diff --git a/docker-compose.yml b/docker-compose.yml
index b82053e57b..5beb3c15b9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: "3.8"
-
services:
excalidraw:
build:
diff --git a/examples/with-script-in-browser/components/MobileFooter.tsx b/examples/with-script-in-browser/components/MobileFooter.tsx
index a6e1fa1b25..94b54c3daa 100644
--- a/examples/with-script-in-browser/components/MobileFooter.tsx
+++ b/examples/with-script-in-browser/components/MobileFooter.tsx
@@ -12,10 +12,10 @@ const MobileFooter = ({
excalidrawAPI: ExcalidrawImperativeAPI;
excalidrawLib: typeof TExcalidraw;
}) => {
- const { useDevice, Footer } = excalidrawLib;
+ const { useEditorInterface, Footer } = excalidrawLib;
- const device = useDevice();
- if (device.editor.isMobile) {
+ const editorInterface = useEditorInterface();
+ if (editorInterface.formFactor === "phone") {
return (
;
-const INPUT_CHANGE_INTERVAL_MS = 500;
-
export type ResolvablePromise = Promise & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void;
@@ -54,40 +52,6 @@ export const fileOpen = (opts: {
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
- legacySetup: (resolve, reject, input) => {
- const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
- const focusHandler = () => {
- checkForFile();
- document.addEventListener("keyup", scheduleRejection);
- document.addEventListener("pointerup", scheduleRejection);
- scheduleRejection();
- };
- const checkForFile = () => {
- // this hack might not work when expecting multiple files
- if (input.files?.length) {
- const ret = opts.multiple ? [...input.files] : input.files[0];
- resolve(ret as RetType);
- }
- };
- requestAnimationFrame(() => {
- window.addEventListener("focus", focusHandler);
- });
- const interval = window.setInterval(() => {
- checkForFile();
- }, INPUT_CHANGE_INTERVAL_MS);
- return (rejectPromise) => {
- clearInterval(interval);
- scheduleRejection.cancel();
- window.removeEventListener("focus", focusHandler);
- document.removeEventListener("keyup", scheduleRejection);
- document.removeEventListener("pointerup", scheduleRejection);
- if (rejectPromise) {
- // so that something is shown in console if we need to debug this
- console.warn("Opening the file was canceled (legacy-fs).");
- rejectPromise(new Error("Request Aborted"));
- }
- };
- },
}) as Promise;
};
diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx
index 932743ddfd..011379a4c1 100644
--- a/excalidraw-app/App.tsx
+++ b/excalidraw-app/App.tsx
@@ -4,6 +4,9 @@ import {
TTDDialogTrigger,
CaptureUpdateAction,
reconcileElements,
+ useEditorInterface,
+ ExcalidrawAPIProvider,
+ useExcalidrawAPI,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -20,7 +23,6 @@ import {
APP_NAME,
EVENT,
THEME,
- TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
@@ -34,7 +36,6 @@ import {
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 {
@@ -48,7 +49,11 @@ import {
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element";
-import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
+import {
+ bumpElementVersions,
+ restoreAppState,
+ restoreElements,
+} from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
@@ -70,6 +75,7 @@ import type {
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
+ ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -105,11 +111,12 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
+ importFromBackend,
isCollaborationLink,
- loadScene,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
+import { FileStatusStore } from "./data/fileStatusStore";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
@@ -120,6 +127,7 @@ import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
+ localStorageQuotaExceededAtom,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -137,6 +145,9 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import "./index.scss";
+import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
+import { AppSidebar } from "./components/AppSidebar";
+
import type { CollabAPI } from "./collab/Collab";
polyfill();
@@ -220,9 +231,20 @@ const initializeScene = async (opts: {
const localDataState = importFromLocalStorage();
- let scene: RestoredDataState & {
+ let scene: Omit<
+ RestoredDataState,
+ // we're not storing files in the scene database/localStorage, and instead
+ // fetch them async from a different store
+ "files"
+ > & {
scrollToContent?: boolean;
- } = await loadScene(null, null, localDataState);
+ } = {
+ elements: restoreElements(localDataState?.elements, null, {
+ repairBindings: true,
+ deleteInvisibleElements: true,
+ }),
+ appState: restoreAppState(localDataState?.appState, null),
+ };
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -236,11 +258,26 @@ const initializeScene = async (opts: {
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
- scene = await loadScene(
+ const imported = await importFromBackend(
jsonBackendMatch[1],
jsonBackendMatch[2],
- localDataState,
);
+
+ scene = {
+ elements: bumpElementVersions(
+ restoreElements(imported.elements, null, {
+ repairBindings: true,
+ deleteInvisibleElements: true,
+ }),
+ localDataState?.elements,
+ ),
+ appState: restoreAppState(
+ imported.appState,
+ // local appState when importing from backend to ensure we restore
+ // localStorage user settings which we do not persist on server.
+ localDataState?.appState,
+ ),
+ };
}
scene.scrollToContent = true;
if (!roomLinkData) {
@@ -335,6 +372,8 @@ const initializeScene = async (opts: {
};
const ExcalidrawWrapper = () => {
+ const excalidrawAPI = useExcalidrawAPI();
+
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
@@ -342,6 +381,8 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode();
+ const editorInterface = useEditorInterface();
+
// initial state
// ---------------------------------------------------------------------------
@@ -363,9 +404,6 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT);
}, []);
- const [excalidrawAPI, excalidrawRefCallback] =
- useCallbackRefState();
-
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -397,18 +435,15 @@ const ExcalidrawWrapper = () => {
}
}, [excalidrawAPI]);
- useEffect(() => {
- if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
- return;
- }
-
- const loadImages = (
- data: ResolutionType,
- isInitialLoad = false,
- ) => {
- if (!data.scene) {
+ // ---------------------------------------------------------------------------
+ // Hoisted loadImages
+ // ---------------------------------------------------------------------------
+ const loadImages = useCallback(
+ (data: ResolutionType, isInitialLoad = false) => {
+ if (!data.scene || !excalidrawAPI) {
return;
}
+
if (collabAPI?.isCollaborating()) {
if (data.scene.elements) {
collabAPI
@@ -435,6 +470,12 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || [];
if (data.isExternalScene) {
+ if (fileIds.length) {
+ // Direct Firebase call (not through FileManager), so track manually
+ FileStatusStore.updateStatuses(
+ fileIds.map((id) => [id, "loading"]),
+ );
+ }
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
@@ -446,12 +487,18 @@ const ExcalidrawWrapper = () => {
erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
});
+ FileStatusStore.updateStatuses([
+ ...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
+ ...[...erroredFiles.keys()].map(
+ (id) => [id, "error"] as [FileId, "error"],
+ ),
+ ]);
});
} else if (isInitialLoad) {
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
- .then(({ loadedFiles, erroredFiles }) => {
+ .then(async ({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
@@ -464,10 +511,19 @@ const ExcalidrawWrapper = () => {
}
// on fresh load, clear unused files from IDB (from previous
// session)
- LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
+ LocalData.fileStorage.clearObsoleteFiles({
+ currentFileIds: fileIds,
+ });
}
}
- };
+ },
+ [collabAPI, excalidrawAPI],
+ );
+
+ useEffect(() => {
+ if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
+ return;
+ }
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true);
@@ -490,8 +546,10 @@ const ExcalidrawWrapper = () => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
- ...data.scene,
- ...restore(data.scene, null, null, { repairBindings: true }),
+ elements: restoreElements(data.scene.elements, null, {
+ repairBindings: true,
+ }),
+ appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}
@@ -499,11 +557,6 @@ const ExcalidrawWrapper = () => {
}
};
- const titleTimeout = setTimeout(
- () => (document.title = APP_NAME),
- TITLE_TIMEOUT,
- );
-
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@@ -594,9 +647,8 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
- clearTimeout(titleTimeout);
};
- }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
+ }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -669,8 +721,8 @@ const ExcalidrawWrapper = () => {
debugRenderer(
debugCanvasRef.current,
appState,
+ elements,
window.devicePixelRatio,
- () => forceRefresh((prev) => !prev),
);
}
};
@@ -734,11 +786,63 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom);
+ const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
+
const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState],
);
+ // ---------------------------------------------------------------------------
+ // onExport — intercepts file save to wait for pending image loads
+ // ---------------------------------------------------------------------------
+ const onExport: Required["onExport"] = useCallback(
+ async function* () {
+ let snapshot = FileStatusStore.getSnapshot();
+ const { pending, total } = FileStatusStore.getPendingCount(
+ snapshot.value,
+ );
+ if (pending === 0) {
+ return;
+ }
+
+ // Yield initial progress
+ yield {
+ type: "progress",
+ progress: (total - pending) / total,
+ message: `Loading images (${total - pending}/${total})...`,
+ };
+
+ // Wait for all pending images to finish
+ while (true) {
+ snapshot = await FileStatusStore.pull(snapshot.version);
+ const { pending: nowPending, total: nowTotal } =
+ FileStatusStore.getPendingCount(snapshot.value);
+
+ yield {
+ type: "progress",
+ progress: (nowTotal - nowPending) / nowTotal,
+ message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
+ };
+
+ if (nowPending === 0) {
+ await new Promise((r) => setTimeout(r, 500));
+ yield {
+ type: "progress",
+ message: `Preparing export...`,
+ };
+ return;
+ }
+ }
+ },
+ [],
+ );
+
+ // const onExport = () => {
+ // return new Promise((r) => setTimeout(r, 2500));
+ // // console.log("onExport");
+ // };
+
// browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
@@ -805,8 +909,8 @@ const ExcalidrawWrapper = () => {
})}
>
{
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
+
return (
-
+
+ {excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
+
+ )}
+
{collabError.message && }
setShareDialogState({ isOpen: true, type: "share" })
}
+ editorInterface={editorInterface}
/>
);
@@ -908,10 +1020,15 @@ const ExcalidrawWrapper = () => {
{isCollaborating && isOffline && (
-
+
{t("alerts.collabOfflineWarning")}
)}
+ {localStorageQuotaExceeded && (
+
+ {t("alerts.localStorageQuotaExceeded")}
+
+ )}
{latestShareableLink && (
{
}}
/>
+
+
{errorMessage && (
setErrorMessage("")}>
{errorMessage}
@@ -1157,7 +1276,9 @@ const ExcalidrawApp = () => {
return (
-
+
+
+
);
diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts
index 1dc6c6f462..e4370e7986 100644
--- a/excalidraw-app/app_constants.ts
+++ b/excalidraw-app/app_constants.ts
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
-export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
+// should be aligned with MAX_ALLOWED_FILE_BYTES
+export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
@@ -45,6 +46,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
+ IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx
index bed130e24a..622f92fb5e 100644
--- a/excalidraw-app/collab/Collab.tsx
+++ b/excalidraw-app/collab/Collab.tsx
@@ -6,7 +6,7 @@ import {
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
-import { APP_NAME, EVENT } from "@excalidraw/common";
+import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle";
import { PureComponent } from "react";
+import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
+
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
@@ -70,6 +72,7 @@ import {
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
+import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
@@ -147,6 +150,7 @@ class Collab extends PureComponent {
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
+ onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
@@ -311,6 +315,7 @@ class Collab extends PureComponent {
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
) => {
+ syncableElements = cloneJSON(syncableElements);
try {
const storedElements = await saveToFirebase(
this.portal,
@@ -441,7 +446,7 @@ class Collab extends PureComponent {
};
private decryptPayload = async (
- iv: Uint8Array,
+ iv: Uint8Array,
encryptedData: ArrayBuffer,
decryptionKey: string,
): Promise> => {
@@ -530,7 +535,10 @@ class Collab extends PureComponent {
return null;
}
- if (!existingRoomLinkData) {
+ if (existingRoomLinkData) {
+ // when joining existing room, don't merge it with current scene data
+ this.excalidrawAPI.resetScene();
+ } else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" });
@@ -559,7 +567,7 @@ class Collab extends PureComponent {
// All socket listeners are moving to Portal
this.portal.socket.on(
"client-broadcast",
- async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
+ async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
@@ -576,7 +584,9 @@ class Collab extends PureComponent {
case WS_SUBTYPES.INIT: {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
- const remoteElements = decryptedData.payload.elements;
+ const remoteElements = toBrandedType<
+ readonly RemoteExcalidrawElement[]
+ >(decryptedData.payload.elements);
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
@@ -590,7 +600,11 @@ class Collab extends PureComponent {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
- this._reconcileElements(decryptedData.payload.elements),
+ this._reconcileElements(
+ toBrandedType(
+ decryptedData.payload.elements,
+ ),
+ ),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -739,17 +753,28 @@ class Collab extends PureComponent {
};
private _reconcileElements = (
- remoteElements: readonly ExcalidrawElement[],
+ remoteElements: readonly RemoteExcalidrawElement[],
): ReconciledExcalidrawElement[] => {
- const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
- const restoredRemoteElements = restoreElements(remoteElements, null);
- const reconciledElements = reconcileElements(
- localElements,
- restoredRemoteElements as RemoteExcalidrawElement[],
+
+ const existingElements = this.getSceneElementsIncludingDeleted();
+
+ // NOTE ideally we restore _after_ reconciliation but we can't do that
+ // as we'd regenerate even elements such as appState.newElement which would
+ // break the state
+ remoteElements = restoreElements(remoteElements, existingElements);
+
+ let reconciledElements = reconcileElements(
+ existingElements,
+ remoteElements,
appState,
);
+ reconciledElements = bumpElementVersions(
+ reconciledElements,
+ existingElements,
+ );
+
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
// Note: this needs to be set before updating the scene as it
diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx
index 9a222a968e..546cfa18dc 100644
--- a/excalidraw-app/components/AI.tsx
+++ b/excalidraw-app/components/AI.tsx
@@ -4,12 +4,15 @@ import {
getTextFromElements,
MIME_TYPES,
TTDDialog,
+ TTDStreamFetch,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
+import { TTDIndexedDBAdapter } from "../data/TTDStorage";
+
export const AIComponents = ({
excalidrawAPI,
}: {
@@ -99,61 +102,23 @@ export const AIComponents = ({
/>
{
- try {
- const response = await fetch(
- `${
- import.meta.env.VITE_APP_AI_BACKEND
- }/v1/ai/text-to-diagram/generate`,
- {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ prompt: input }),
- },
- );
+ onTextSubmit={async (props) => {
+ const { onChunk, onStreamCreated, signal, messages } = props;
- const rateLimit = response.headers.has("X-Ratelimit-Limit")
- ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
- : undefined;
+ const result = await TTDStreamFetch({
+ url: `${
+ import.meta.env.VITE_APP_AI_BACKEND
+ }/v1/ai/text-to-diagram/chat-streaming`,
+ messages,
+ onChunk,
+ onStreamCreated,
+ extractRateLimits: true,
+ signal,
+ });
- const rateLimitRemaining = response.headers.has(
- "X-Ratelimit-Remaining",
- )
- ? parseInt(
- response.headers.get("X-Ratelimit-Remaining") || "0",
- 10,
- )
- : undefined;
-
- const json = await response.json();
-
- if (!response.ok) {
- if (response.status === 429) {
- return {
- rateLimit,
- rateLimitRemaining,
- error: new Error(
- "Too many requests today, please try again tomorrow!",
- ),
- };
- }
-
- throw new Error(json.message || "Generation failed...");
- }
-
- const generatedResponse = json.generatedResponse;
- if (!generatedResponse) {
- throw new Error("Generation failed...");
- }
-
- return { generatedResponse, rateLimit, rateLimitRemaining };
- } catch (err: any) {
- throw new Error("Request failed");
- }
+ return result;
}}
+ persistenceAdapter={TTDIndexedDBAdapter}
/>
>
);
diff --git a/excalidraw-app/components/AppFooter.tsx b/excalidraw-app/components/AppFooter.tsx
index 7d42d46bf8..5e2cb27cb3 100644
--- a/excalidraw-app/components/AppFooter.tsx
+++ b/excalidraw-app/components/AppFooter.tsx
@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
-import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
}}
>
{isVisualDebuggerEnabled() && }
- {isExcalidrawPlusSignedUser ? (
-
- ) : (
-
- )}
+ {!isExcalidrawPlusSignedUser && }
);
diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx
index cd0aca2683..a3f847385f 100644
--- a/excalidraw-app/components/AppMainMenu.tsx
+++ b/excalidraw-app/components/AppMainMenu.tsx
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
{isDevEnv() && (
{
+ onSelect={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
@@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{
)}
+
{
+ const { theme, openSidebar } = useUIAppState();
+
+ return (
+
+
+
+ {messageCircleIcon}
+
+
+ {presentationIcon}
+
+
+
+
+
+
+ Make comments with Excalidraw+
+
+
+ Sign up now
+
+
+
+
+
+
+
+ Create presentations with Excalidraw+
+
+
+ Sign up now
+
+
+
+
+ );
+};
diff --git a/excalidraw-app/components/AppWelcomeScreen.tsx b/excalidraw-app/components/AppWelcomeScreen.tsx
index 5bb5073e9c..903af7a5f7 100644
--- a/excalidraw-app/components/AppWelcomeScreen.tsx
+++ b/excalidraw-app/components/AppWelcomeScreen.tsx
@@ -33,7 +33,15 @@ export const AppWelcomeScreen: React.FC<{
return bit;
});
} else {
- headingContent = t("welcomeScreen.app.center_heading");
+ headingContent = (
+ <>
+ {t("welcomeScreen.app.center_heading")}
+
+ {t("welcomeScreen.app.center_heading_line2")}
+
+ {t("welcomeScreen.app.center_heading_line3")}
+ >
+ );
}
return (
diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx
index 71e3885b12..d9279296f7 100644
--- a/excalidraw-app/components/DebugCanvas.tsx
+++ b/excalidraw-app/components/DebugCanvas.tsx
@@ -8,9 +8,15 @@ import {
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
-import { throttleRAF } from "@excalidraw/common";
+import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
+import {
+ getGlobalFixedPointForBindableElement,
+ isArrowElement,
+ isBindableElement,
+} from "@excalidraw/element";
+
import {
isLineSegment,
type GlobalPoint,
@@ -21,8 +27,17 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math";
-
-import type { DebugElement } from "@excalidraw/utils/visualdebug";
+import type {
+ DebugElement,
+ DebugPolygon,
+} from "@excalidraw/element/visualdebug";
+import type {
+ ElementsMap,
+ ExcalidrawArrowElement,
+ ExcalidrawBindableElement,
+ FixedPointBinding,
+ OrderedExcalidrawElement,
+} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
@@ -63,6 +78,44 @@ const renderCubicBezier = (
context.restore();
};
+const renderPolygon = (
+ context: CanvasRenderingContext2D,
+ zoom: number,
+ polygon: DebugPolygon,
+ color: string,
+) => {
+ const { points, fill, close } = polygon;
+
+ if (points.length < 2) {
+ return;
+ }
+
+ context.save();
+ context.beginPath();
+ context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
+ for (let i = 1; i < points.length; i += 1) {
+ context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
+ }
+ if (close !== false) {
+ context.closePath();
+ }
+
+ if (fill) {
+ context.save();
+ context.globalAlpha = 0.15;
+ context.fillStyle = color;
+ context.fill();
+ context.restore();
+ }
+
+ context.strokeStyle = color;
+ context.stroke();
+ context.restore();
+};
+
+const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
+ (data as DebugPolygon).type === "polygon";
+
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
@@ -75,6 +128,176 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save();
};
+const _renderBinding = (
+ context: CanvasRenderingContext2D,
+ binding: FixedPointBinding,
+ elementsMap: ElementsMap,
+ zoom: number,
+ width: number,
+ height: number,
+ color: string,
+) => {
+ if (!binding.fixedPoint) {
+ console.warn("Binding must have a fixedPoint");
+ return;
+ }
+
+ const bindable = elementsMap.get(
+ binding.elementId,
+ ) as ExcalidrawBindableElement;
+ const [x, y] = getGlobalFixedPointForBindableElement(
+ binding.fixedPoint,
+ bindable,
+ elementsMap,
+ );
+
+ context.save();
+ context.strokeStyle = color;
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(x * zoom, y * zoom);
+ context.bezierCurveTo(
+ x * zoom - width,
+ y * zoom - height,
+ x * zoom - width,
+ y * zoom + height,
+ x * zoom,
+ y * zoom,
+ );
+ context.stroke();
+ context.restore();
+};
+
+const _renderBindableBinding = (
+ binding: FixedPointBinding,
+ context: CanvasRenderingContext2D,
+ elementsMap: ElementsMap,
+ zoom: number,
+ width: number,
+ height: number,
+ color: string,
+) => {
+ const bindable = elementsMap.get(
+ binding.elementId,
+ ) as ExcalidrawBindableElement;
+ if (!binding.fixedPoint) {
+ console.warn("Binding must have a fixedPoint");
+ return;
+ }
+
+ const [x, y] = getGlobalFixedPointForBindableElement(
+ binding.fixedPoint,
+ bindable,
+ elementsMap,
+ );
+
+ context.save();
+ context.strokeStyle = color;
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(x * zoom, y * zoom);
+ context.bezierCurveTo(
+ x * zoom + width,
+ y * zoom + height,
+ x * zoom + width,
+ y * zoom - height,
+ x * zoom,
+ y * zoom,
+ );
+ context.stroke();
+ context.restore();
+};
+
+const renderBindings = (
+ context: CanvasRenderingContext2D,
+ elements: readonly OrderedExcalidrawElement[],
+ zoom: number,
+) => {
+ const elementsMap = arrayToMap(elements);
+ const dim = 16;
+ elements.forEach((element) => {
+ if (element.isDeleted) {
+ return;
+ }
+
+ if (isArrowElement(element)) {
+ if (element.startBinding) {
+ if (
+ !elementsMap
+ .get(element.startBinding.elementId)
+ ?.boundElements?.find((e) => e.id === element.id)
+ ) {
+ return;
+ }
+
+ _renderBinding(
+ context,
+ element.startBinding,
+ elementsMap,
+ zoom,
+ dim,
+ dim,
+ element.startBinding?.mode === "orbit" ? "red" : "black",
+ );
+ }
+
+ if (element.endBinding) {
+ if (
+ !elementsMap
+ .get(element.endBinding.elementId)
+ ?.boundElements?.find((e) => e.id === element.id)
+ ) {
+ return;
+ }
+ _renderBinding(
+ context,
+ element.endBinding,
+ elementsMap,
+ zoom,
+ dim,
+ dim,
+ element.endBinding?.mode === "orbit" ? "red" : "black",
+ );
+ }
+ }
+
+ if (isBindableElement(element) && element.boundElements?.length) {
+ element.boundElements.forEach((boundElement) => {
+ if (boundElement.type !== "arrow") {
+ return;
+ }
+
+ const arrow = elementsMap.get(
+ boundElement.id,
+ ) as ExcalidrawArrowElement;
+
+ if (arrow && arrow.startBinding?.elementId === element.id) {
+ _renderBindableBinding(
+ arrow.startBinding,
+ context,
+ elementsMap,
+ zoom,
+ dim,
+ dim,
+ "green",
+ );
+ }
+ if (arrow && arrow.endBinding?.elementId === element.id) {
+ _renderBindableBinding(
+ arrow.endBinding,
+ context,
+ elementsMap,
+ zoom,
+ dim,
+ dim,
+ "green",
+ );
+ }
+ });
+ }
+ });
+};
+
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
@@ -98,6 +321,9 @@ const render = (
el.color,
);
break;
+ case isDebugPolygon(el.data):
+ renderPolygon(context, appState.zoom.value, el.data, el.color);
+ break;
default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
}
@@ -107,8 +333,8 @@ const render = (
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
+ elements: readonly OrderedExcalidrawElement[],
scale: number,
- refresh: () => void,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
@@ -131,6 +357,7 @@ const _debugRenderer = (
);
renderOrigin(context, appState.zoom.value);
+ renderBindings(context, elements, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
@@ -182,12 +409,11 @@ export const debugRenderer = throttleRAF(
(
canvas: HTMLCanvasElement,
appState: AppState,
+ elements: readonly OrderedExcalidrawElement[],
scale: number,
- refresh: () => void,
) => {
- _debugRenderer(canvas, appState, scale, refresh);
+ _debugRenderer(canvas, appState, elements, scale);
},
- { trailing: true },
);
export const loadSavedDebugState = () => {
diff --git a/excalidraw-app/components/ExcalidrawPlusAppLink.tsx b/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
deleted file mode 100644
index 04cd963022..0000000000
--- a/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { isExcalidrawPlusSignedUser } from "../app_constants";
-
-export const ExcalidrawPlusAppLink = () => {
- if (!isExcalidrawPlusSignedUser) {
- return null;
- }
- return (
-
- Go to Excalidraw+
-
- );
-};
diff --git a/excalidraw-app/components/ExcalidrawPlusPromoBanner.tsx b/excalidraw-app/components/ExcalidrawPlusPromoBanner.tsx
new file mode 100644
index 0000000000..8cd0aa1ef9
--- /dev/null
+++ b/excalidraw-app/components/ExcalidrawPlusPromoBanner.tsx
@@ -0,0 +1,22 @@
+export const ExcalidrawPlusPromoBanner = ({
+ isSignedIn,
+}: {
+ isSignedIn: boolean;
+}) => {
+ return (
+
+ Excalidraw+
+
+ );
+};
diff --git a/excalidraw-app/components/GitHubCorner.tsx b/excalidraw-app/components/GitHubCorner.tsx
deleted file mode 100644
index 6a4108813c..0000000000
--- a/excalidraw-app/components/GitHubCorner.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { THEME } from "@excalidraw/common";
-import oc from "open-color";
-import React from "react";
-
-import type { Theme } from "@excalidraw/element/types";
-
-// https://github.com/tholman/github-corners
-export const GitHubCorner = React.memo(
- ({ theme, dir }: { theme: Theme; dir: string }) => (
-
-
-
-
-
-
-
- ),
-);
diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts
index 435d813252..849e47bc92 100644
--- a/excalidraw-app/data/FileManager.ts
+++ b/excalidraw-app/data/FileManager.ts
@@ -40,10 +40,12 @@ export class FileManager {
private _getFiles;
private _saveFiles;
+ private _onFileStatusChange;
constructor({
getFiles,
saveFiles,
+ onFileStatusChange,
}: {
getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[];
@@ -53,9 +55,13 @@ export class FileManager {
savedFiles: Map;
erroredFiles: Map;
}>;
+ onFileStatusChange?: (
+ updates: Array<[FileId, "loading" | "loaded" | "error"]>,
+ ) => void;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
+ this._onFileStatusChange = onFileStatusChange;
}
/**
@@ -146,6 +152,8 @@ export class FileManager {
this.fetchingFiles.set(id, true);
}
+ this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
+
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -156,6 +164,13 @@ export class FileManager {
this.erroredFiles_fetch.set(fileId, true);
}
+ this._onFileStatusChange?.([
+ ...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
+ ...[...erroredFiles.keys()].map(
+ (id) => [id, "error"] as [FileId, "error"],
+ ),
+ ]);
+
return { loadedFiles, erroredFiles };
} finally {
for (const id of ids) {
@@ -195,6 +210,13 @@ export class FileManager {
};
reset() {
+ if (this._onFileStatusChange && this.fetchingFiles.size) {
+ this._onFileStatusChange(
+ [...this.fetchingFiles.keys()].map(
+ (id) => [id, "error"] as [FileId, "error"],
+ ),
+ );
+ }
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts
index 9ad6dc9256..634fdc4f1c 100644
--- a/excalidraw-app/data/LocalData.ts
+++ b/excalidraw-app/data/LocalData.ts
@@ -16,7 +16,6 @@ import {
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
-import { clearElementsForLocalStorage } from "@excalidraw/element";
import {
createStore,
entries,
@@ -27,6 +26,9 @@ import {
get,
} from "idb-keyval";
+import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
+import { getNonDeletedElements } from "@excalidraw/element";
+
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -40,11 +42,14 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
+import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
+export const localStorageQuotaExceededAtom = atom(false);
+
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => {
@@ -69,6 +74,9 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
+ const localStorageQuotaExceeded = appJotaiStore.get(
+ localStorageQuotaExceededAtom,
+ );
try {
const _appState = clearAppStateForLocalStorage(appState);
@@ -81,19 +89,29 @@ const saveDataStateToLocalStorage = (
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
- JSON.stringify(clearElementsForLocalStorage(elements)),
+ JSON.stringify(getNonDeletedElements(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
+ if (localStorageQuotaExceeded) {
+ appJotaiStore.set(localStorageQuotaExceededAtom, false);
+ }
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
+ if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
+ appJotaiStore.set(localStorageQuotaExceededAtom, true);
+ }
}
};
+const isQuotaExceededError = (error: any) => {
+ return error instanceof DOMException && error.name === "QuotaExceededError";
+};
+
type SavingLockTypes = "collaboration";
export class LocalData {
@@ -149,6 +167,7 @@ export class LocalData {
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
+ onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) {
return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => {
diff --git a/excalidraw-app/data/TTDStorage.ts b/excalidraw-app/data/TTDStorage.ts
new file mode 100644
index 0000000000..d7fe5d89e7
--- /dev/null
+++ b/excalidraw-app/data/TTDStorage.ts
@@ -0,0 +1,51 @@
+import { createStore, get, set } from "idb-keyval";
+
+import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
+
+import { STORAGE_KEYS } from "../app_constants";
+
+/**
+ * IndexedDB adapter for TTD chat storage.
+ * Implements TTDPersistenceAdapter interface.
+ */
+export class TTDIndexedDBAdapter {
+ /** IndexedDB database name */
+ private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
+ /** Store key for chat data */
+ private static key = "ttdChats";
+
+ private static store = createStore(
+ `${TTDIndexedDBAdapter.idb_name}-db`,
+ `${TTDIndexedDBAdapter.idb_name}-store`,
+ );
+
+ /**
+ * Load saved chats from IndexedDB.
+ * @returns Promise resolving to saved chats array (empty if none found)
+ */
+ static async loadChats(): Promise {
+ try {
+ const data = await get(
+ TTDIndexedDBAdapter.key,
+ TTDIndexedDBAdapter.store,
+ );
+ return data || [];
+ } catch (error) {
+ console.warn("Failed to load TTD chats from IndexedDB:", error);
+ return [];
+ }
+ }
+
+ /**
+ * Save chats to IndexedDB.
+ * @param chats - The chats array to persist
+ */
+ static async saveChats(chats: SavedChats): Promise {
+ try {
+ await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
+ } catch (error) {
+ console.warn("Failed to save TTD chats to IndexedDB:", error);
+ throw error;
+ }
+ }
+}
diff --git a/excalidraw-app/data/fileStatusStore.ts b/excalidraw-app/data/fileStatusStore.ts
new file mode 100644
index 0000000000..ff9363ed5d
--- /dev/null
+++ b/excalidraw-app/data/fileStatusStore.ts
@@ -0,0 +1,48 @@
+import { VersionedSnapshotStore } from "@excalidraw/common";
+
+import type { FileId } from "@excalidraw/element/types";
+
+export type FileLoadingStatus = "loading" | "loaded" | "error";
+
+export class FileStatusStore {
+ private static store = new VersionedSnapshotStore<
+ Map
+ >(new Map());
+
+ static getSnapshot() {
+ return this.store.getSnapshot();
+ }
+
+ static pull(sinceVersion?: number) {
+ return this.store.pull(sinceVersion);
+ }
+
+ static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
+ if (!updates.length) {
+ return;
+ }
+ this.store.update((prev) => {
+ let changed = false;
+ const next = new Map(prev);
+ for (const [id, status] of updates) {
+ if (next.get(id) !== status) {
+ next.set(id, status);
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+ }
+
+ static getPendingCount(statuses: Map) {
+ let pending = 0;
+ let total = 0;
+ for (const status of statuses.values()) {
+ total++;
+ if (status === "loading") {
+ pending++;
+ }
+ }
+ return { pending, total };
+ }
+}
diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts
index 568054f7ef..11177d90aa 100644
--- a/excalidraw-app/data/firebase.ts
+++ b/excalidraw-app/data/firebase.ts
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw";
-import { MIME_TYPES } from "@excalidraw/common";
+import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene,
roomKey: string,
): Promise => {
- const ciphertext = data.ciphertext.toUint8Array();
- const iv = data.iv.toUint8Array();
+ const ciphertext = data.ciphertext.toUint8Array() as Uint8Array;
+ const iv = data.iv.toUint8Array() as Uint8Array;
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements);
- return storedElements;
+ return toBrandedType(storedElements);
};
export const loadFromFirebase = async (
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
}
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
- restoreElements(await decryptElements(storedScene, roomKey), null),
+ restoreElements(await decryptElements(storedScene, roomKey), null, {
+ deleteInvisibleElements: true,
+ }),
);
if (socket) {
diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts
index 75aa278779..bf765941a2 100644
--- a/excalidraw-app/data/index.ts
+++ b/excalidraw-app/data/index.ts
@@ -8,7 +8,6 @@ import {
IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
-import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: {
type: WS_SUBTYPES.INIT;
payload: {
- elements: readonly ExcalidrawElement[];
+ elements: readonly OrderedExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
- elements: readonly ExcalidrawElement[];
+ elements: readonly OrderedExcalidrawElement[];
};
};
MOUSE_LOCATION: {
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
};
};
-const importFromBackend = async (
+export const importFromBackend = async (
id: string,
decryptionKey: string,
): Promise => {
@@ -242,40 +241,6 @@ const importFromBackend = async (
}
};
-export const loadScene = async (
- id: string | null,
- privateKey: string | null,
- // Supply local state even if importing from backend to ensure we restore
- // localStorage user settings which we do not persist on server.
- // Non-optional so we don't forget to pass it even if `undefined`.
- localDataState: ImportedDataState | undefined | null,
-) => {
- let data;
- if (id != null && privateKey != null) {
- // the private key is used to decrypt the content from the server, take
- // extra care not to leak it
- data = restore(
- await importFromBackend(id, privateKey),
- localDataState?.appState,
- localDataState?.elements,
- { repairBindings: true, refreshDimensions: false },
- );
- } else {
- data = restore(localDataState || null, null, null, {
- repairBindings: true,
- });
- }
-
- return {
- elements: data.elements,
- appState: data.appState,
- // note: this will always be empty because we're not storing files
- // in the scene database/localStorage, and instead fetch them async
- // from a different database
- files: data.files,
- };
-};
-
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts
index bc0df4a678..28c166cd74 100644
--- a/excalidraw-app/data/localStorage.ts
+++ b/excalidraw-app/data/localStorage.ts
@@ -2,7 +2,6 @@ import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
-import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -50,7 +49,7 @@ export const importFromLocalStorage = () => {
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
- elements = clearElementsForLocalStorage(JSON.parse(savedElements));
+ elements = JSON.parse(savedElements);
} catch (error: any) {
console.error(error);
// Do nothing because elements array is already empty
diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html
index 750824c585..1c29d7220a 100644
--- a/excalidraw-app/index.html
+++ b/excalidraw-app/index.html
@@ -2,7 +2,7 @@
- Excalidraw | Hand-drawn look & feel • Collaborative • Secure
+ Excalidraw Whiteboard
=18.0.0"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
@@ -36,6 +36,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",
+ "uqr": "0.1.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
diff --git a/excalidraw-app/sentry.ts b/excalidraw-app/sentry.ts
index 30b84f3f69..58e34bba53 100644
--- a/excalidraw-app/sentry.ts
+++ b/excalidraw-app/sentry.ts
@@ -1,3 +1,4 @@
+import { getFeatureFlag } from "@excalidraw/common";
import * as Sentry from "@sentry/browser";
import callsites from "callsites";
@@ -33,6 +34,7 @@ Sentry.init({
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
+ Sentry.featureFlagsIntegration(),
],
beforeSend(event) {
if (event.request?.url) {
@@ -79,3 +81,14 @@ Sentry.init({
return event;
},
});
+
+const flagsIntegration =
+ Sentry.getClient()?.getIntegrationByName(
+ "FeatureFlags",
+ );
+if (flagsIntegration) {
+ flagsIntegration.addFeatureFlag(
+ "COMPLEX_BINDINGS",
+ getFeatureFlag("COMPLEX_BINDINGS"),
+ );
+}
diff --git a/excalidraw-app/share/QRCode.tsx b/excalidraw-app/share/QRCode.tsx
new file mode 100644
index 0000000000..4418328319
--- /dev/null
+++ b/excalidraw-app/share/QRCode.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from "react";
+import Spinner from "@excalidraw/excalidraw/components/Spinner";
+
+interface QRCodeProps {
+ value: string;
+}
+
+export const QRCode = ({ value }: QRCodeProps) => {
+ const [svgData, setSvgData] = useState(null);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+
+ import("./qrcode.chunk")
+ .then(({ generateQRCodeSVG }) => {
+ if (mounted) {
+ try {
+ setSvgData(generateQRCodeSVG(value));
+ } catch {
+ setError(true);
+ }
+ }
+ })
+ .catch(() => {
+ if (mounted) {
+ setError(true);
+ }
+ });
+
+ return () => {
+ mounted = false;
+ };
+ }, [value]);
+
+ if (error) {
+ return null;
+ }
+
+ if (!svgData) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/excalidraw-app/share/ShareDialog.scss b/excalidraw-app/share/ShareDialog.scss
index 436f411248..169cc03bd1 100644
--- a/excalidraw-app/share/ShareDialog.scss
+++ b/excalidraw-app/share/ShareDialog.scss
@@ -140,6 +140,31 @@
gap: 0.75rem;
}
+ &__qrcode {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ align-self: center;
+ padding: 1rem;
+ background: #fff;
+ border-radius: 0.5rem;
+ border: 1px solid #e0e0e0;
+
+ $size: 150px;
+ width: $size;
+ height: $size;
+
+ & svg {
+ width: $size;
+ height: $size;
+ }
+
+ &--loading {
+ background: var(--island-bg-color);
+ border: 1px solid var(--dialog-border-color);
+ }
+ }
+
&__description {
border-top: 1px solid var(--color-gray-20);
diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx
index 884d64680d..051393910a 100644
--- a/excalidraw-app/share/ShareDialog.tsx
+++ b/excalidraw-app/share/ShareDialog.tsx
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss";
+import { QRCode } from "./QRCode";
import type { CollabAPI } from "../collab/Collab";
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
}}
/>
+
{
+ return renderSVG(text);
+};
diff --git a/excalidraw-app/tests/MobileMenu.test.tsx b/excalidraw-app/tests/MobileMenu.test.tsx
index 3b05e72968..70f2162f9b 100644
--- a/excalidraw-app/tests/MobileMenu.test.tsx
+++ b/excalidraw-app/tests/MobileMenu.test.tsx
@@ -17,30 +17,15 @@ describe("Test MobileMenu", () => {
beforeEach(async () => {
await render( );
- // @ts-ignore
- h.app.refreshViewportBreakpoints();
- // @ts-ignore
- h.app.refreshEditorBreakpoints();
+ h.app.refreshEditorInterface();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
- it("should set device correctly", () => {
- expect(h.app.device).toMatchInlineSnapshot(`
- {
- "editor": {
- "canFitSidebar": false,
- "isMobile": true,
- },
- "isTouchScreen": false,
- "viewport": {
- "isLandscape": false,
- "isMobile": true,
- },
- }
- `);
+ it("should set editor interface correctly", () => {
+ expect(h.app.editorInterface.formFactor).toBe("phone");
});
it("should initialize with welcome screen and hide once user interacts", async () => {
diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
index 53b16c40ff..8bdc0f99ff 100644
--- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
+++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
@@ -50,7 +50,11 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
- All your data is saved locally in your browser.
+ Your drawings are saved in your browser's storage.
+
+ Browser storage can be cleared unexpectedly.
+
+ Save your work to a file regularly to avoid losing it.