Merge remote-tracking branch 'origin/master' into aakansha-detect-browser-zoom

This commit is contained in:
Aakansha Doshi 2022-07-06 13:57:06 +05:30
commit c1f972179a
88 changed files with 2887 additions and 3226 deletions

View file

@ -11,3 +11,12 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002
REACT_APP_PORTAL_URL= REACT_APP_PORTAL_URL=
REACT_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"}' REACT_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"}'
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=

View file

@ -1,37 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- lipis
assignees:
- lipis
open-pull-requests-limit: 20
- package-ecosystem: npm
directory: /src/packages/excalidraw/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
open-pull-requests-limit: 20
- package-ecosystem: npm
directory: /src/packages/utils/
schedule:
interval: weekly
day: sunday
time: "01:00"
reviewers:
- ad1992
assignees:
- ad1992
open-pull-requests-limit: 20

View file

@ -1,4 +1,4 @@
name: Auto release @excalidraw/excalidraw-next name: Auto release excalidraw next
on: on:
push: push:
branches: branches:

View file

@ -1,4 +1,4 @@
name: Auto release preview @excalidraw/excalidraw-preview name: Auto release excalidraw preview
on: on:
issue_comment: issue_comment:
types: [created, edited] types: [created, edited]
@ -6,7 +6,7 @@ on:
jobs: jobs:
Auto-release-excalidraw-preview: Auto-release-excalidraw-preview:
name: Auto release preview name: Auto release preview
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: React to release comment - name: React to release comment

View file

@ -94,7 +94,8 @@
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build:prebuild": "node ./scripts/prebuild.js",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",

View file

@ -98,6 +98,22 @@
/> />
<link rel="stylesheet" href="fonts.css" type="text/css" /> <link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
<script>
{
const _WebSocket = window.WebSocket;
window.WebSocket = function (url) {
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
console.info(
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
);
} else {
return new _WebSocket(url);
}
};
}
</script>
<% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/"; window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.

View file

@ -17,11 +17,23 @@
* See https://goo.gl/2aRDsh * See https://goo.gl/2aRDsh
*/ */
importScripts("/workbox/workbox-sw.js"); // in dev, `process` is undefined because this file is not compiled until build
const IS_DEVELOPMENT =
typeof process === "undefined" || process.env.NODE_ENV !== "production";
workbox.setConfig({ if (IS_DEVELOPMENT) {
modulePathPrefix: "/workbox/", importScripts(
}); "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
);
workbox.setConfig({
debug: true,
});
} else {
importScripts("/workbox/workbox-sw.js");
workbox.setConfig({
modulePathPrefix: "/workbox/",
});
}
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") { if (event.data && event.data.type === "SKIP_WAITING") {
@ -30,14 +42,17 @@ self.addEventListener("message", (event) => {
}); });
workbox.core.clientsClaim(); workbox.core.clientsClaim();
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerNavigationRoute( if (!IS_DEVELOPMENT) {
workbox.precaching.getCacheKeyForURL("./index.html"), workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
{
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/], workbox.routing.registerNavigationRoute(
}, workbox.precaching.getCacheKeyForURL("./index.html"),
); {
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
},
);
}
// Cache relevant font files // Cache relevant font files
workbox.routing.registerRoute( workbox.routing.registerRoute(

View file

@ -5,22 +5,25 @@ const core = require("@actions/core");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
const isPreview = process.argv.slice(2)[0] === "preview";
const getShortCommitHash = () => { const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim(); return execSync("git rev-parse --short HEAD").toString().trim();
}; };
const publish = () => { const publish = () => {
const tag = isPreview ? "preview" : "next";
try { try {
execSync(`yarn --frozen-lockfile`); execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir }); execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`); execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
console.info("Published 🎉"); console.info(`Published ${pkg.name}@${tag}🎉`);
core.setOutput( core.setOutput(
"result", "result",
`**Preview version has been shipped** :rocket: `**Preview version has been shipped** :rocket:
You can use [@excalidraw/excalidraw-preview@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw-preview/v/${pkg.version}) for testing!`, You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
); );
} catch (error) { } catch (error) {
core.setOutput("result", "package couldn't be published :warning:!"); core.setOutput("result", "package couldn't be published :warning:!");
@ -51,27 +54,19 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
} }
// update package.json // update package.json
pkg.name = "@excalidraw/excalidraw-next";
let version = `${pkg.version}-${getShortCommitHash()}`; let version = `${pkg.version}-${getShortCommitHash()}`;
// update readme // update readme
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
const isPreview = process.argv.slice(2)[0] === "preview";
if (isPreview) { if (isPreview) {
// use pullNumber-commithash as the version for preview // use pullNumber-commithash as the version for preview
const pullRequestNumber = process.argv.slice(3)[0]; const pullRequestNumber = process.argv.slice(3)[0];
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`; version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
// replace "excalidraw-next" with "excalidraw-preview"
pkg.name = "@excalidraw/excalidraw-preview";
data = data.replace(/excalidraw-next/g, "excalidraw-preview");
data = data.trim();
} }
pkg.version = version; pkg.version = version;
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8"); fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
console.info("Publish in progress..."); console.info("Publish in progress...");
publish(); publish();
}); });

20
scripts/prebuild.js Normal file
View file

@ -0,0 +1,20 @@
const fs = require("fs");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = "./public/service-worker.js";
const newPath = "./src/service-worker.js";
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();

View file

@ -1,11 +1,21 @@
const fs = require("fs"); const fs = require("fs");
const util = require("util"); const util = require("util");
const exec = util.promisify(require("child_process").exec); const exec = util.promisify(require("child_process").exec);
const updateReadme = require("./updateReadme");
const updateChangelog = require("./updateChangelog"); const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
const updateReadme = () => {
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
// remove note for stable readme
const data = originalReadMe.slice(excalidrawIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
const updatePackageVersion = (nextVersion) => { const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
@ -23,8 +33,10 @@ const release = async (nextVersion) => {
await exec( await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`, `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
); );
/* eslint-disable no-console */ // revert readme after release
console.log("Done!"); fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Done!");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
process.exit(1); process.exit(1);

View file

@ -1,27 +0,0 @@
const fs = require("fs");
const updateReadme = () => {
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
// remove note for unstable release
data = data.replace(
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
"",
);
// replace "excalidraw-next" with "excalidraw"
data = data.replace(/excalidraw-next/g, "excalidraw");
data = data.trim();
const demoIndex = data.indexOf("### Demo");
const excalidrawNextNote =
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
// Add excalidraw next note to try out for unreleased changes
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
module.exports = updateReadme;

View file

@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "../components/App"; import { useDevice } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
@ -204,7 +204,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs} icon={saveAs}
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useDeviceType().isMobile} showAriaLabel={useDevice().isMobile}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
@ -248,7 +248,7 @@ export const actionLoadScene = register({
icon={load} icon={load}
title={t("buttons.load")} title={t("buttons.load")}
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
showAriaLabel={useDeviceType().isMobile} showAriaLabel={useDevice().isMobile}
onClick={updateData} onClick={updateData}
data-testid="load-button" data-testid="load-button"
/> />

View file

@ -19,7 +19,7 @@ import { AppState } from "../types";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
trackEvent: false, trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer }) => { perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
@ -50,8 +50,12 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
if (appState.pendingImageElement) { const pendingImageElement =
mutateElement(appState.pendingImageElement, { isDeleted: true }, false); appState.pendingImageElementId &&
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
} }
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
@ -177,7 +181,7 @@ export const actionFinalize = register({
[multiPointElement.id]: true, [multiPointElement.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
pendingImageElement: null, pendingImageElementId: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", commitToHistory: appState.activeTool.type === "freedraw",
}; };

View file

@ -31,16 +31,7 @@ export const actionGoToCollaborator = register({
}; };
}, },
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const clientId: string | undefined = data?.id; const [clientId, collaborator] = data as [string, Collaborator];
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const { background, stroke } = getClientColors(clientId, appState); const { background, stroke } = getClientColors(clientId, appState);

View file

@ -48,7 +48,7 @@ describe("actionStyles", () => {
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C); Keyboard.codeDown(CODES.C);
}); });
const secondRect = JSON.parse(copiedStyles); const secondRect = JSON.parse(copiedStyles)[0];
expect(secondRect.id).toBe(h.elements[1].id); expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset(); mouse.reset();

View file

@ -6,13 +6,15 @@ import {
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { t } from "../i18n"; import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getContainerElement } from "../element/textElement"; import { getBoundTextElement } from "../element/textElement";
import { hasBoundTextElement } from "../element/typeChecks";
import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@ -21,9 +23,15 @@ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const elementsCopied = [];
const element = elements.find((el) => appState.selectedElementIds[el.id]); const element = elements.find((el) => appState.selectedElementIds[el.id]);
elementsCopied.push(element);
if (element && hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element);
elementsCopied.push(boundTextElement);
}
if (element) { if (element) {
copiedStyles = JSON.stringify(element); copiedStyles = JSON.stringify(elementsCopied);
} }
return { return {
appState: { appState: {
@ -42,31 +50,62 @@ export const actionPasteStyles = register({
name: "pasteStyles", name: "pasteStyles",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles); const elementsCopied = JSON.parse(copiedStyles);
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElementIds = selectedElements.map((element) => element.id);
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (appState.selectedElementIds[element.id]) { if (selectedElementIds.includes(element.id)) {
const newElement = newElementWith(element, { let elementStylesToCopyFrom = pastedElement;
backgroundColor: pastedElement?.backgroundColor, if (isTextElement(element) && element.containerId) {
strokeWidth: pastedElement?.strokeWidth, elementStylesToCopyFrom = boundTextElement;
strokeColor: pastedElement?.strokeColor,
strokeStyle: pastedElement?.strokeStyle,
fillStyle: pastedElement?.fillStyle,
opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness,
});
if (isTextElement(newElement) && isTextElement(element)) {
mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
});
redrawTextBoundingBox(newElement, getContainerElement(newElement));
} }
if (!elementStylesToCopyFrom) {
return element;
}
let newElement = newElementWith(element, {
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
strokeColor: elementStylesToCopyFrom?.strokeColor,
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
});
if (isTextElement(newElement)) {
newElement = newElementWith(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
fontFamily:
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
});
let container = null;
if (newElement.containerId) {
container =
selectedElements.find(
(element) =>
isTextElement(newElement) &&
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(newElement, container);
}
if (newElement.type === "arrow") {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,
});
}
return newElement; return newElement;
} }
return element; return element;

View file

@ -30,7 +30,7 @@ const trackAction = (
trackEvent( trackEvent(
action.trackEvent.category, action.trackEvent.category,
action.trackEvent.action || action.name, action.trackEvent.action || action.name,
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`, `${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
); );
} }
} }

View file

@ -6,7 +6,6 @@ import {
ExcalidrawProps, ExcalidrawProps,
BinaryFiles, BinaryFiles,
} from "../types"; } from "../types";
import { ToolButtonSize } from "../components/ToolButton";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
@ -119,7 +118,7 @@ export type PanelComponentProps = {
appState: AppState; appState: AppState;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
appProps: ExcalidrawProps; appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>; data?: Record<string, any>;
}; };
export interface Action { export interface Action {

View file

@ -58,6 +58,7 @@ export const getDefaultAppState = (): Omit<
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
isLibraryOpen: false, isLibraryOpen: false,
isLibraryMenuDocked: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@ -87,7 +88,7 @@ export const getDefaultAppState = (): Omit<
value: 1 as NormalizedZoomValue, value: 1 as NormalizedZoomValue,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElement: null, pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
}; };
}; };
@ -146,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: false, export: false, server: false }, isLibraryOpen: { browser: true, export: false, server: false },
isLibraryMenuDocked: { browser: true, export: false, server: false },
isLoading: { browser: false, export: false, server: false }, isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false },
@ -177,7 +179,7 @@ const APP_STATE_STORAGE_CONF = (<
zenModeEnabled: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false }, zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false }, pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
}); });

View file

@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "../components/App"; import { useDevice } from "../components/App";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
@ -52,7 +52,7 @@ export const SelectedShapeActions = ({
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const isEditing = Boolean(appState.editingElement); const isEditing = Boolean(appState.editingElement);
const deviceType = useDeviceType(); const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
@ -177,8 +177,8 @@ export const SelectedShapeActions = ({
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
{!deviceType.isMobile && renderAction("duplicateSelection")} {!device.isMobile && renderAction("duplicateSelection")}
{!deviceType.isMobile && renderAction("deleteSelectedElements")} {!device.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}

View file

@ -64,6 +64,8 @@ import {
MQ_MAX_HEIGHT_LANDSCAPE, MQ_MAX_HEIGHT_LANDSCAPE,
MQ_MAX_WIDTH_LANDSCAPE, MQ_MAX_WIDTH_LANDSCAPE,
MQ_MAX_WIDTH_PORTRAIT, MQ_MAX_WIDTH_PORTRAIT,
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
MQ_SM_MAX_WIDTH,
POINTER_BUTTON, POINTER_BUTTON,
SCROLL_TIMEOUT, SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT, TAP_TWICE_TIMEOUT,
@ -194,7 +196,7 @@ import {
LibraryItems, LibraryItems,
PointerDownState, PointerDownState,
SceneData, SceneData,
DeviceType, Device,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -220,7 +222,6 @@ import {
} from "../utils"; } from "../utils";
import ContextMenu, { ContextMenuOption } from "./ContextMenu"; import ContextMenu, { ContextMenuOption } from "./ContextMenu";
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
import { Stats } from "./Stats";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { import {
@ -259,12 +260,14 @@ import {
isLocalLink, isLocalLink,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
const defaultDeviceTypeContext: DeviceType = { const deviceContextInitialValue = {
isSmScreen: false,
isMobile: false, isMobile: false,
isTouchScreen: false, isTouchScreen: false,
canDeviceFitSidebar: false,
}; };
const DeviceTypeContext = React.createContext(defaultDeviceTypeContext); const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
export const useDeviceType = () => useContext(DeviceTypeContext); export const useDevice = () => useContext<Device>(DeviceContext);
const ExcalidrawContainerContext = React.createContext<{ const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null; container: HTMLDivElement | null;
id: string | null; id: string | null;
@ -296,10 +299,7 @@ class App extends React.Component<AppProps, AppState> {
rc: RoughCanvas | null = null; rc: RoughCanvas | null = null;
unmounted: boolean = false; unmounted: boolean = false;
actionManager: ActionManager; actionManager: ActionManager;
deviceType: DeviceType = { device: Device = deviceContextInitialValue;
isMobile: false,
isTouchScreen: false,
};
detachIsMobileMqHandler?: () => void; detachIsMobileMqHandler?: () => void;
private excalidrawContainerRef = React.createRef<HTMLDivElement>(); private excalidrawContainerRef = React.createRef<HTMLDivElement>();
@ -309,7 +309,7 @@ class App extends React.Component<AppProps, AppState> {
UIOptions: DEFAULT_UI_OPTIONS, UIOptions: DEFAULT_UI_OPTIONS,
}; };
private scene: Scene; public scene: Scene;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
@ -353,12 +353,12 @@ class App extends React.Component<AppProps, AppState> {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
showHyperlinkPopup: false, showHyperlinkPopup: false,
isLibraryMenuDocked: false,
}; };
this.id = nanoid(); this.id = nanoid();
this.library = new Library(this); this.library = new Library(this);
if (excalidrawRef) { if (excalidrawRef) {
const readyPromise = const readyPromise =
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) || ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
@ -485,7 +485,7 @@ class App extends React.Component<AppProps, AppState> {
<div <div
className={clsx("excalidraw excalidraw-container", { className={clsx("excalidraw excalidraw-container", {
"excalidraw--view-mode": viewModeEnabled, "excalidraw--view-mode": viewModeEnabled,
"excalidraw--mobile": this.deviceType.isMobile, "excalidraw--mobile": this.device.isMobile,
})} })}
ref={this.excalidrawContainerRef} ref={this.excalidrawContainerRef}
onDrop={this.handleAppOnDrop} onDrop={this.handleAppOnDrop}
@ -497,7 +497,7 @@ class App extends React.Component<AppProps, AppState> {
<ExcalidrawContainerContext.Provider <ExcalidrawContainerContext.Provider
value={this.excalidrawContainerValue} value={this.excalidrawContainerValue}
> >
<DeviceTypeContext.Provider value={this.deviceType}> <DeviceContext.Provider value={this.device}>
<LayerUI <LayerUI
canvas={this.canvas} canvas={this.canvas}
appState={this.state} appState={this.state}
@ -521,6 +521,7 @@ class App extends React.Component<AppProps, AppState> {
isCollaborating={this.props.isCollaborating} isCollaborating={this.props.isCollaborating}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomFooter={renderFooter} renderCustomFooter={renderFooter}
renderCustomStats={renderCustomStats}
viewModeEnabled={viewModeEnabled} viewModeEnabled={viewModeEnabled}
showExitZenModeBtn={ showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && typeof this.props?.zenModeEnabled === "undefined" &&
@ -548,15 +549,6 @@ class App extends React.Component<AppProps, AppState> {
onLinkOpen={this.props.onLinkOpen} onLinkOpen={this.props.onLinkOpen}
/> />
)} )}
{this.state.showStats && (
<Stats
appState={this.state}
setAppState={this.setAppState}
elements={this.scene.getNonDeletedElements()}
onClose={this.toggleStats}
renderCustomStats={renderCustomStats}
/>
)}
{this.state.toastMessage !== null && ( {this.state.toastMessage !== null && (
<Toast <Toast
message={this.state.toastMessage} message={this.state.toastMessage}
@ -564,7 +556,7 @@ class App extends React.Component<AppProps, AppState> {
/> />
)} )}
<main>{this.renderCanvas()}</main> <main>{this.renderCanvas()}</main>
</DeviceTypeContext.Provider> </DeviceContext.Provider>
</ExcalidrawContainerContext.Provider> </ExcalidrawContainerContext.Provider>
</div> </div>
); );
@ -763,7 +755,12 @@ class App extends React.Component<AppProps, AppState> {
const scene = restore(initialData, null, null); const scene = restore(initialData, null, null);
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
isLibraryOpen: this.state.isLibraryOpen, // we're falling back to current (pre-init) state when deciding
// whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library)
isLibraryOpen:
initialData?.appState?.isLibraryOpen || this.state.isLibraryOpen,
activeTool: activeTool:
scene.appState.activeTool.type === "image" scene.appState.activeTool.type === "image"
? { ...scene.appState.activeTool, type: "selection" } ? { ...scene.appState.activeTool, type: "selection" }
@ -794,6 +791,21 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
private refreshDeviceState = (container: HTMLDivElement) => {
const { width, height } = container.getBoundingClientRect();
const sidebarBreakpoint =
this.props.UIOptions.dockedSidebarBreakpoint != null
? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
this.device = updateObject(this.device, {
isSmScreen: width < MQ_SM_MAX_WIDTH,
isMobile:
width < MQ_MAX_WIDTH_PORTRAIT ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
canDeviceFitSidebar: width > sidebarBreakpoint,
});
};
public async componentDidMount() { public async componentDidMount() {
this.unmounted = false; this.unmounted = false;
this.excalidrawContainerValue.container = this.excalidrawContainerValue.container =
@ -835,34 +847,53 @@ class App extends React.Component<AppProps, AppState> {
this.focusContainer(); this.focusContainer();
} }
if (
this.excalidrawContainerRef.current &&
// bounding rects don't work in tests so updating
// the state on init would result in making the test enviro run
// in mobile breakpoint (0 width/height), making everything fail
process.env.NODE_ENV !== "test"
) {
this.refreshDeviceState(this.excalidrawContainerRef.current);
}
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) { if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
// compute isMobile state // recompute device dimensions state
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
const { width, height } = this.refreshDeviceState(this.excalidrawContainerRef.current!);
this.excalidrawContainerRef.current!.getBoundingClientRect();
this.deviceType = updateObject(this.deviceType, {
isMobile:
width < MQ_MAX_WIDTH_PORTRAIT ||
(height < MQ_MAX_HEIGHT_LANDSCAPE &&
width < MQ_MAX_WIDTH_LANDSCAPE),
});
// refresh offsets // refresh offsets
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
this.updateDOMRect(); this.updateDOMRect();
}); });
this.resizeObserver?.observe(this.excalidrawContainerRef.current); this.resizeObserver?.observe(this.excalidrawContainerRef.current);
} else if (window.matchMedia) { } else if (window.matchMedia) {
const mediaQuery = window.matchMedia( const mdScreenQuery = window.matchMedia(
`(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`, `(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
); );
const smScreenQuery = window.matchMedia(
`(max-width: ${MQ_SM_MAX_WIDTH}px)`,
);
const canDeviceFitSidebarMediaQuery = window.matchMedia(
`(min-width: ${
// NOTE this won't update if a different breakpoint is supplied
// after mount
this.props.UIOptions.dockedSidebarBreakpoint != null
? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH
}px)`,
);
const handler = () => { const handler = () => {
this.deviceType = updateObject(this.deviceType, { this.excalidrawContainerRef.current!.getBoundingClientRect();
isMobile: mediaQuery.matches, this.device = updateObject(this.device, {
isSmScreen: smScreenQuery.matches,
isMobile: mdScreenQuery.matches,
canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches,
}); });
}; };
mediaQuery.addListener(handler); mdScreenQuery.addListener(handler);
this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler); this.detachIsMobileMqHandler = () =>
mdScreenQuery.removeListener(handler);
} }
const searchParams = new URLSearchParams(window.location.search.slice(1)); const searchParams = new URLSearchParams(window.location.search.slice(1));
@ -887,7 +918,7 @@ class App extends React.Component<AppProps, AppState> {
} }
private onResize = withBatchedUpdates(() => { private onResize = withBatchedUpdates(() => {
if (!this.deviceType.isMobile) { if (!this.device.isMobile) {
const scrollBarWidth = 10; const scrollBarWidth = 10;
const widthRatio = const widthRatio =
(window.outerWidth - scrollBarWidth) / window.innerWidth; (window.outerWidth - scrollBarWidth) / window.innerWidth;
@ -1016,6 +1047,14 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (
this.excalidrawContainerRef.current &&
prevProps.UIOptions.dockedSidebarBreakpoint !==
this.props.UIOptions.dockedSidebarBreakpoint
) {
this.refreshDeviceState(this.excalidrawContainerRef.current);
}
if ( if (
prevState.scrollX !== this.state.scrollX || prevState.scrollX !== this.state.scrollX ||
prevState.scrollY !== this.state.scrollY prevState.scrollY !== this.state.scrollY
@ -1154,8 +1193,7 @@ class App extends React.Component<AppProps, AppState> {
if (isImageElement(element)) { if (isImageElement(element)) {
if ( if (
// not placed on canvas yet (but in elements array) // not placed on canvas yet (but in elements array)
this.state.pendingImageElement && this.state.pendingImageElementId === element.id
element.id === this.state.pendingImageElement.id
) { ) {
return false; return false;
} }
@ -1189,7 +1227,7 @@ class App extends React.Component<AppProps, AppState> {
theme: this.state.theme, theme: this.state.theme,
imageCache: this.imageCache, imageCache: this.imageCache,
isExporting: false, isExporting: false,
renderScrollbars: !this.deviceType.isMobile, renderScrollbars: !this.device.isMobile,
}, },
); );
@ -1467,11 +1505,15 @@ class App extends React.Component<AppProps, AppState> {
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
this.history.resumeRecording(); this.history.resumeRecording();
this.setState( this.setState(
selectGroupsForSelectedElements( selectGroupsForSelectedElements(
{ {
...this.state, ...this.state,
isLibraryOpen: false, isLibraryOpen:
this.state.isLibraryOpen && this.device.canDeviceFitSidebar
? this.state.isLibraryMenuDocked
: false,
selectedElementIds: newElements.reduce((map, element) => { selectedElementIds: newElements.reduce((map, element) => {
if (!isBoundToContainer(element)) { if (!isBoundToContainer(element)) {
map[element.id] = true; map[element.id] = true;
@ -1543,7 +1585,7 @@ class App extends React.Component<AppProps, AppState> {
trackEvent( trackEvent(
"toolbar", "toolbar",
"toggleLock", "toggleLock",
`${source} (${this.deviceType.isMobile ? "mobile" : "desktop"})`, `${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
); );
} }
this.setState((prevState) => { this.setState((prevState) => {
@ -1574,10 +1616,6 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionToggleZenMode); this.actionManager.executeAction(actionToggleZenMode);
}; };
toggleStats = () => {
this.actionManager.executeAction(actionToggleStats);
};
scrollToContent = ( scrollToContent = (
target: target:
| ExcalidrawElement | ExcalidrawElement
@ -1706,9 +1744,13 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
// bail if
if ( if (
(isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || // inside an input
// case: using arrows to move between buttons (isWritableElement(event.target) &&
// unless pressing escape (finalize action)
event.key !== KEYS.ESCAPE) ||
// or unless using arrows (to move between buttons)
(isArrowKey(event.key) && isInputLike(event.target)) (isArrowKey(event.key) && isInputLike(event.target))
) { ) {
return; return;
@ -1733,7 +1775,16 @@ class App extends React.Component<AppProps, AppState> {
} }
if (event.code === CODES.ZERO) { if (event.code === CODES.ZERO) {
this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); const nextState = !this.state.isLibraryOpen;
this.setState({ isLibraryOpen: nextState });
// track only openings
if (nextState) {
trackEvent(
"library",
"toggleLibrary (open)",
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
);
}
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
@ -1827,7 +1878,7 @@ class App extends React.Component<AppProps, AppState> {
trackEvent( trackEvent(
"toolbar", "toolbar",
shape, shape,
`keyboard (${this.deviceType.isMobile ? "mobile" : "desktop"})`, `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
); );
} }
this.setActiveTool({ type: shape }); this.setActiveTool({ type: shape });
@ -2237,12 +2288,13 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = selectedElements[0]; existingTextElement = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[0], false)) { } else if (isTextBindableContainer(selectedElements[0], false)) {
existingTextElement = getBoundTextElement(selectedElements[0]); existingTextElement = getBoundTextElement(selectedElements[0]);
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
existingTextElement =
existingTextElement ?? this.getTextElementAtPosition(sceneX, sceneY);
// bind to container when shouldBind is true or // bind to container when shouldBind is true or
// clicked on center of container // clicked on center of container
if ( if (
@ -2451,7 +2503,7 @@ class App extends React.Component<AppProps, AppState> {
element, element,
this.state, this.state,
[scenePointer.x, scenePointer.y], [scenePointer.x, scenePointer.y],
this.deviceType.isMobile, this.device.isMobile,
) )
); );
}); });
@ -2483,7 +2535,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement, this.hitLinkElement,
this.state, this.state,
[lastPointerDownCoords.x, lastPointerDownCoords.y], [lastPointerDownCoords.x, lastPointerDownCoords.y],
this.deviceType.isMobile, this.device.isMobile,
); );
const lastPointerUpCoords = viewportCoordsToSceneCoords( const lastPointerUpCoords = viewportCoordsToSceneCoords(
this.lastPointerUp!, this.lastPointerUp!,
@ -2493,7 +2545,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement, this.hitLinkElement,
this.state, this.state,
[lastPointerUpCoords.x, lastPointerUpCoords.y], [lastPointerUpCoords.x, lastPointerUpCoords.y],
this.deviceType.isMobile, this.device.isMobile,
); );
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
const url = this.hitLinkElement.link; const url = this.hitLinkElement.link;
@ -2932,10 +2984,10 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
!this.deviceType.isTouchScreen && !this.device.isTouchScreen &&
["pen", "touch"].includes(event.pointerType) ["pen", "touch"].includes(event.pointerType)
) { ) {
this.deviceType = updateObject(this.deviceType, { isTouchScreen: true }); this.device = updateObject(this.device, { isTouchScreen: true });
} }
if (isPanning) { if (isPanning) {
@ -3012,19 +3064,24 @@ class App extends React.Component<AppProps, AppState> {
// reset image preview on pointerdown // reset image preview on pointerdown
setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR); setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR);
if (!this.state.pendingImageElement) { // retrieve the latest element as the state may be stale
const pendingImageElement =
this.state.pendingImageElementId &&
this.scene.getElement(this.state.pendingImageElementId);
if (!pendingImageElement) {
return; return;
} }
this.setState({ this.setState({
draggingElement: this.state.pendingImageElement, draggingElement: pendingImageElement,
editingElement: this.state.pendingImageElement, editingElement: pendingImageElement,
pendingImageElement: null, pendingImageElementId: null,
multiElement: null, multiElement: null,
}); });
const { x, y } = viewportCoordsToSceneCoords(event, this.state); const { x, y } = viewportCoordsToSceneCoords(event, this.state);
mutateElement(this.state.pendingImageElement, { mutateElement(pendingImageElement, {
x, x,
y, y,
}); });
@ -3072,7 +3129,7 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
this.lastPointerUp = event; this.lastPointerUp = event;
if (this.deviceType.isTouchScreen) { if (this.device.isTouchScreen) {
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY }, { clientX: event.clientX, clientY: event.clientY },
this.state, this.state,
@ -3090,7 +3147,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
this.redirectToLink(event, this.deviceType.isTouchScreen); this.redirectToLink(event, this.device.isTouchScreen);
} }
this.removePointer(event); this.removePointer(event);
@ -3462,7 +3519,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.element, pointerDownState.hit.element,
this.state, this.state,
[pointerDownState.origin.x, pointerDownState.origin.y], [pointerDownState.origin.x, pointerDownState.origin.y],
this.deviceType.isMobile, this.device.isMobile,
) )
) { ) {
return false; return false;
@ -4340,8 +4397,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.eventListeners.onKeyUp!, pointerDownState.eventListeners.onKeyUp!,
); );
if (this.state.pendingImageElement) { if (this.state.pendingImageElementId) {
this.setState({ pendingImageElement: null }); this.setState({ pendingImageElementId: null });
} }
if (draggingElement?.type === "freedraw") { if (draggingElement?.type === "freedraw") {
@ -4829,7 +4886,7 @@ class App extends React.Component<AppProps, AppState> {
await cachedImageData.image; await cachedImageData.image;
} }
if ( if (
this.state.pendingImageElement?.id !== imageElement.id && this.state.pendingImageElementId !== imageElement.id &&
this.state.draggingElement?.id !== imageElement.id this.state.draggingElement?.id !== imageElement.id
) { ) {
this.initializeImageDimensions(imageElement, true); this.initializeImageDimensions(imageElement, true);
@ -4911,7 +4968,7 @@ class App extends React.Component<AppProps, AppState> {
previewDataURL = canvas.toDataURL(MIME_TYPES.svg) as DataURL; previewDataURL = canvas.toDataURL(MIME_TYPES.svg) as DataURL;
} }
if (this.state.pendingImageElement) { if (this.state.pendingImageElementId) {
setCursor(this.canvas, `url(${previewDataURL}) 4 4, auto`); setCursor(this.canvas, `url(${previewDataURL}) 4 4, auto`);
} }
}; };
@ -4952,7 +5009,7 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
this.setState( this.setState(
{ {
pendingImageElement: imageElement, pendingImageElementId: imageElement.id,
}, },
() => { () => {
this.insertImageElement( this.insertImageElement(
@ -4971,7 +5028,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.setState( this.setState(
{ {
pendingImageElement: null, pendingImageElementId: null,
editingElement: null, editingElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, { type: "selection" }),
}, },
@ -5569,7 +5626,7 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
ContextMenu.push({ ContextMenu.push({
options: [ options: [
this.deviceType.isMobile && this.device.isMobile &&
navigator.clipboard && { navigator.clipboard && {
trackEvent: false, trackEvent: false,
name: "paste", name: "paste",
@ -5581,7 +5638,7 @@ class App extends React.Component<AppProps, AppState> {
}, },
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
}, },
this.deviceType.isMobile && navigator.clipboard && separator, this.device.isMobile && navigator.clipboard && separator,
probablySupportsClipboardBlob && probablySupportsClipboardBlob &&
elements.length > 0 && elements.length > 0 &&
actionCopyAsPng, actionCopyAsPng,
@ -5626,9 +5683,9 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
ContextMenu.push({ ContextMenu.push({
options: [ options: [
this.deviceType.isMobile && actionCut, this.device.isMobile && actionCut,
this.deviceType.isMobile && navigator.clipboard && actionCopy, this.device.isMobile && navigator.clipboard && actionCopy,
this.deviceType.isMobile && this.device.isMobile &&
navigator.clipboard && { navigator.clipboard && {
name: "paste", name: "paste",
trackEvent: false, trackEvent: false,
@ -5640,7 +5697,7 @@ class App extends React.Component<AppProps, AppState> {
}, },
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
}, },
this.deviceType.isMobile && separator, this.device.isMobile && separator,
...options, ...options,
separator, separator,
actionCopyStyles, actionCopyStyles,
@ -5891,10 +5948,10 @@ if (
elements: { elements: {
configurable: true, configurable: true,
get() { get() {
return this.app.scene.getElementsIncludingDeleted(); return this.app?.scene.getElementsIncludingDeleted();
}, },
set(elements: ExcalidrawElement[]) { set(elements: ExcalidrawElement[]) {
return this.app.scene.replaceAllElements(elements); return this.app?.scene.replaceAllElements(elements);
}, },
}, },
}); });

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "./App"; import { useDevice } from "./App";
import { trash } from "./icons"; import { trash } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
icon={trash} icon={trash}
title={t("buttons.clearReset")} title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")} aria-label={t("buttons.clearReset")}
showAriaLabel={useDeviceType().isMobile} showAriaLabel={useDevice().isMobile}
onClick={toggleDialog} onClick={toggleDialog}
data-testid="clear-canvas-button" data-testid="clear-canvas-button"
/> />

View file

@ -18,13 +18,15 @@
left: -5px; left: -5px;
} }
min-width: 1em; min-width: 1em;
min-height: 1em;
line-height: 1;
position: absolute; position: absolute;
bottom: -5px; bottom: -5px;
padding: 3px; padding: 3px;
border-radius: 50%; border-radius: 50%;
background-color: $oc-green-6; background-color: $oc-green-6;
color: $oc-white; color: $oc-white;
font-size: 0.7em; font-size: 0.6em;
font-family: var(--ui-font); font-family: "Cascadia";
} }
} }

View file

@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "../components/App"; import { useDevice } from "../components/App";
import { users } from "./icons"; import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
@ -26,9 +26,9 @@ const CollabButton = ({
type="button" type="button"
title={t("labels.liveCollaboration")} title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")}
showAriaLabel={useDeviceType().isMobile} showAriaLabel={useDevice().isMobile}
> >
{collaboratorCount > 0 && ( {isCollaborating && (
<div className="CollabButton-collaborators">{collaboratorCount}</div> <div className="CollabButton-collaborators">{collaboratorCount}</div>
)} )}
</ToolButton> </ToolButton>

View file

@ -128,31 +128,21 @@ const Picker = ({
}, []); }, []);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === KEYS.TAB) { let handled = false;
const { activeElement } = document; if (isArrowKey(event.key)) {
if (event.shiftKey) { handled = true;
if (activeElement === firstItem.current) {
colorInput.current?.focus();
event.preventDefault();
}
} else if (activeElement === colorInput.current) {
firstItem.current?.focus();
event.preventDefault();
}
} else if (isArrowKey(event.key)) {
const { activeElement } = document; const { activeElement } = document;
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
let isCustom = false; let isCustom = false;
let index = Array.prototype.indexOf.call( let index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--default")! gallery.current!.querySelector(".color-picker-content--default")
.children, ?.children,
activeElement, activeElement,
); );
if (index === -1) { if (index === -1) {
index = Array.prototype.indexOf.call( index = Array.prototype.indexOf.call(
gallery.current!.querySelector( gallery.current!.querySelector(".color-picker-content--canvas-colors")
".color-picker-content--canvas-colors", ?.children,
)!.children,
activeElement, activeElement,
); );
if (index !== -1) { if (index !== -1) {
@ -180,8 +170,11 @@ const Picker = ({
event.preventDefault(); event.preventDefault();
} else if ( } else if (
keyBindings.includes(event.key.toLowerCase()) && keyBindings.includes(event.key.toLowerCase()) &&
!event[KEYS.CTRL_OR_CMD] &&
!event.altKey &&
!isWritableElement(event.target) !isWritableElement(event.target)
) { ) {
handled = true;
const index = keyBindings.indexOf(event.key.toLowerCase()); const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS; const isCustom = index >= MAX_DEFAULT_COLORS;
const parentElement = isCustom const parentElement = isCustom
@ -196,11 +189,14 @@ const Picker = ({
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
handled = true;
event.preventDefault(); event.preventDefault();
onClose(); onClose();
} }
event.nativeEvent.stopImmediatePropagation(); if (handled) {
event.stopPropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}
}; };
const renderColors = (colors: Array<string>, custom: boolean = false) => { const renderColors = (colors: Array<string>, custom: boolean = false) => {
@ -264,7 +260,8 @@ const Picker = ({
gallery.current = el; gallery.current = el;
} }
}} }}
tabIndex={0} // to allow focusing by clicking but not by tabbing
tabIndex={-1}
> >
<div className="color-picker-content--default"> <div className="color-picker-content--default">
{renderColors(colors)} {renderColors(colors)}

View file

@ -2,13 +2,14 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer, useDeviceType } from "../components/App"; import { useExcalidrawContainer, useDevice } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, close } from "./icons"; import { back, close } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@ -64,14 +65,6 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown); return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const queryFocusableElements = (node: HTMLElement) => {
const focusableElements = node.querySelectorAll<HTMLElement>(
"button, a, input, select, textarea, div[tabindex]",
);
return focusableElements ? Array.from(focusableElements) : [];
};
const onClose = () => { const onClose = () => {
(lastActiveElement as HTMLElement).focus(); (lastActiveElement as HTMLElement).focus();
props.onCloseRequest(); props.onCloseRequest();
@ -94,7 +87,7 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose} onClick={onClose}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useDeviceType().isMobile ? back : close} {useDevice().isMobile ? back : close}
</button> </button>
</h2> </h2>
<div className="Dialog__content">{props.children}</div> <div className="Dialog__content">{props.children}</div>

View file

@ -45,7 +45,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text"); return t("hints.text");
} }
if (appState.activeTool.type === "image" && appState.pendingImageElement) { if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage"); return t("hints.placeImage");
} }

View file

@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "./App"; import { useDevice } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
@ -250,7 +250,7 @@ export const ImageExportDialog = ({
icon={exportImage} icon={exportImage}
type="button" type="button"
aria-label={t("buttons.exportImage")} aria-label={t("buttons.exportImage")}
showAriaLabel={useDeviceType().isMobile} showAriaLabel={useDevice().isMobile}
title={t("buttons.exportImage")} title={t("buttons.exportImage")}
/> />
{modalIsShown && ( {modalIsShown && (

View file

@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
useEffect(() => { useEffect(() => {
const updateLang = async () => { const updateLang = async () => {
await setLanguage(currentLang); await setLanguage(currentLang);
setLoading(false);
}; };
const currentLang = const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang; languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang(); updateLang();
setLoading(false);
}, [props.langCode]); }, [props.langCode]);
return loading ? <LoadingMessage /> : props.children; return loading ? <LoadingMessage /> : props.children;

View file

@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "./App"; import { useDevice } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons"; import { exportFile, exportToFileIcon, link } from "./icons";
@ -117,7 +117,7 @@ export const JSONExportDialog = ({
icon={exportFile} icon={exportFile}
type="button" type="button"
aria-label={t("buttons.export")} aria-label={t("buttons.export")}
showAriaLabel={useDeviceType().isMobile} showAriaLabel={useDevice().isMobile}
title={t("buttons.export")} title={t("buttons.export")}
/> />
{modalIsShown && ( {modalIsShown && (

View file

@ -1,9 +1,63 @@
@import "open-color/open-color"; @import "open-color/open-color";
@import "../css/variables.module";
.layer-ui__sidebar {
position: absolute;
top: var(--sat);
bottom: var(--sab);
right: var(--sar);
z-index: 5;
box-shadow: var(--shadow-island);
overflow: hidden;
border-radius: var(--border-radius-lg);
margin: var(--space-factor);
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
}
.excalidraw { .excalidraw {
.layer-ui__wrapper.animate {
transition: width 0.1s ease-in-out;
}
.layer-ui__wrapper { .layer-ui__wrapper {
// when the rightside sidebar is docked, we need to resize the UI by its
// width, making the nested UI content shift to the left. To do this,
// we need the UI container to actually have dimensions set, but
// then we also need to disable pointer events else the canvas below
// wouldn't be interactive.
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--zIndex-layerUI); z-index: var(--zIndex-layerUI);
&__top-right { &__top-right {
display: flex; display: flex;
} }

View file

@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants"; import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
@ -25,7 +25,6 @@ import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section"; import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
@ -37,7 +36,9 @@ import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useDeviceType } from "../components/App"; import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@ -56,14 +57,9 @@ interface LayerUIProps {
toggleZenMode: () => void; toggleZenMode: () => void;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
renderTopRightUI?: ( renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
isMobile: boolean, renderCustomFooter?: ExcalidrawProps["renderFooter"];
appState: AppState, renderCustomStats?: ExcalidrawProps["renderCustomStats"];
) => JSX.Element | null;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
viewModeEnabled: boolean; viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
@ -72,7 +68,6 @@ interface LayerUIProps {
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
} }
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@ -91,6 +86,7 @@ const LayerUI = ({
isCollaborating, isCollaborating,
renderTopRightUI, renderTopRightUI,
renderCustomFooter, renderCustomFooter,
renderCustomStats,
viewModeEnabled, viewModeEnabled,
libraryReturnUrl, libraryReturnUrl,
UIOptions, UIOptions,
@ -99,7 +95,7 @@ const LayerUI = ({
id, id,
onImageAction, onImageAction,
}: LayerUIProps) => { }: LayerUIProps) => {
const deviceType = useDeviceType(); const device = useDevice();
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
@ -345,7 +341,7 @@ const LayerUI = ({
<HintViewer <HintViewer
appState={appState} appState={appState}
elements={elements} elements={elements}
isMobile={deviceType.isMobile} isMobile={device.isMobile}
/> />
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
@ -367,7 +363,6 @@ const LayerUI = ({
setAppState={setAppState} setAppState={setAppState}
/> />
</Stack.Row> </Stack.Row>
{libraryMenu}
</Stack.Col> </Stack.Col>
)} )}
</Section> </Section>
@ -380,23 +375,11 @@ const LayerUI = ({
}, },
)} )}
> >
<UserList> <UserList
{appState.collaborators.size > 0 && collaborators={appState.collaborators}
Array.from(appState.collaborators) actionManager={actionManager}
// Collaborator is either not initialized or is actually the current user. />
.filter(([_, client]) => Object.keys(client).length !== 0) {renderTopRightUI?.(device.isMobile, appState)}
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(deviceType.isMobile, appState)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@ -449,7 +432,7 @@ const LayerUI = ({
)} )}
{!viewModeEnabled && {!viewModeEnabled &&
appState.multiElement && appState.multiElement &&
deviceType.isTouchScreen && ( device.isTouchScreen && (
<div <div
className={clsx("finalize-button zen-mode-transition", { className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left": "layer-ui__wrapper__footer-left--transition-left":
@ -526,7 +509,24 @@ const LayerUI = ({
</> </>
); );
return deviceType.isMobile ? ( const renderStats = () => {
if (!appState.showStats) {
return null;
}
return (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
);
};
return device.isMobile ? (
<> <>
{dialogs} {dialogs}
<MobileMenu <MobileMenu
@ -547,33 +547,48 @@ const LayerUI = ({
showThemeBtn={showThemeBtn} showThemeBtn={showThemeBtn}
onImageAction={onImageAction} onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderStats={renderStats}
/> />
</> </>
) : ( ) : (
<div <>
className={clsx("layer-ui__wrapper", { <div
"disable-pointerEvents": className={clsx("layer-ui__wrapper", {
appState.draggingElement || "disable-pointerEvents":
appState.resizingElement || appState.draggingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)), appState.resizingElement ||
})} (appState.editingElement &&
> !isTextElement(appState.editingElement)),
{dialogs} })}
{renderFixedSideContainer()} style={
{renderBottomAppMenu()} appState.isLibraryOpen &&
{appState.scrolledOutside && ( appState.isLibraryMenuDocked &&
<button device.canDeviceFitSidebar
className="scroll-back-to-content" ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
onClick={() => { : {}
setAppState({ }
...calculateScrollCenter(elements, appState, canvas), >
}); {dialogs}
}} {renderFixedSideContainer()}
> {renderBottomAppMenu()}
{t("buttons.scrollBackToContent")} {renderStats()}
</button> {appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
{appState.isLibraryOpen && (
<div className="layer-ui__sidebar">{libraryMenu}</div>
)} )}
</div> </>
); );
}; };

View file

@ -3,6 +3,8 @@ import clsx from "clsx";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState } from "../types"; import { AppState } from "../types";
import { capitalizeString } from "../utils"; import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
const LIBRARY_ICON = ( const LIBRARY_ICON = (
<svg viewBox="0 0 576 512"> <svg viewBox="0 0 576 512">
@ -18,6 +20,7 @@ export const LibraryButton: React.FC<{
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean; isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => { }> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
return ( return (
<label <label
className={clsx( className={clsx(
@ -34,7 +37,19 @@ export const LibraryButton: React.FC<{
type="checkbox" type="checkbox"
name="editor-library" name="editor-library"
onChange={(event) => { onChange={(event) => {
setAppState({ isLibraryOpen: event.target.checked }); document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const nextState = event.target.checked;
setAppState({ isLibraryOpen: nextState });
// track only openings
if (nextState) {
trackEvent(
"library",
"toggleLibrary (open)",
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}} }}
checked={appState.isLibraryOpen} checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))} aria-label={capitalizeString(t("toolBar.library"))}

View file

@ -2,7 +2,6 @@
.excalidraw { .excalidraw {
.layer-ui__library { .layer-ui__library {
margin: auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -11,8 +10,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin: 2px 0; margin: 2px 0 15px 0;
.Spinner { .Spinner {
margin-right: 1rem; margin-right: 1rem;
} }
@ -21,13 +19,17 @@
// 2px from the left to account for focus border of left-most button // 2px from the left to account for focus border of left-most button
margin: 0 2px; margin: 0 2px;
} }
}
}
a { .layer-ui__sidebar {
margin-inline-start: auto; .layer-ui__library {
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra padding: 0;
padding-inline-end: 18px; height: 100%;
white-space: nowrap; }
} .library-menu-items-container {
height: 100%;
width: 100%;
} }
} }
@ -65,4 +67,38 @@
} }
} }
} }
.library-menu-browse-button {
width: 80%;
min-height: 22px;
margin: 0 auto;
margin-top: 1rem;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
border-radius: var(--border-radius-lg);
background-color: var(--color-primary);
color: $oc-white;
text-align: center;
white-space: nowrap;
text-decoration: none !important;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
}
.library-menu-browse-button--mobile {
min-height: 22px;
margin-left: auto;
a {
padding-right: 0;
}
}
} }

View file

@ -29,6 +29,7 @@ import { trackEvent } from "../analytics";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { useDevice } from "./App";
const useOnClickOutside = ( const useOnClickOutside = (
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
@ -103,17 +104,30 @@ export const LibraryMenu = ({
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => { const device = useDevice();
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) { useOnClickOutside(
return; ref,
} useCallback(
onClose(); (event) => {
}); // If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
onClose();
}
},
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
),
);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) { if (
event.key === KEYS.ESCAPE &&
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
) {
onClose(); onClose();
} }
}; };
@ -121,7 +135,7 @@ export const LibraryMenu = ({
return () => { return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
}; };
}, [onClose]); }, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] = const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
@ -273,6 +287,7 @@ export const LibraryMenu = ({
onInsertLibraryItems={onInsertLibraryItems} onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements} pendingElements={pendingElements}
setAppState={setAppState} setAppState={setAppState}
appState={appState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
library={library} library={library}
theme={theme} theme={theme}

View file

@ -2,8 +2,17 @@
.excalidraw { .excalidraw {
.library-menu-items-container { .library-menu-items-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 0.5rem;
box-sizing: border-box;
.library-actions { .library-actions {
width: 100%;
display: flex; display: flex;
margin-right: auto;
align-items: center;
button .library-actions-counter { button .library-actions-counter {
position: absolute; position: absolute;
@ -87,12 +96,16 @@
} }
} }
&__items { &__items {
max-height: 50vh; flex: 1;
overflow: auto; overflow-y: auto;
margin-top: 0.5rem; overflow-x: hidden;
margin-bottom: 1rem;
} }
.separator { .separator {
width: 100%;
display: flex;
align-items: center;
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
margin: 0.6em 0.2em; margin: 0.6em 0.2em;

View file

@ -12,9 +12,9 @@ import {
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
import { arrayToMap, muteFSAbortError } from "../utils"; import { arrayToMap, muteFSAbortError } from "../utils";
import { useDeviceType } from "./App"; import { useDevice } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons"; import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -25,6 +25,9 @@ import { MIME_TYPES, VERSIONS } from "../constants";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { SidebarLockButton } from "./SidebarLockButton";
import { trackEvent } from "../analytics";
const LibraryMenuItems = ({ const LibraryMenuItems = ({
isLoading, isLoading,
libraryItems, libraryItems,
@ -34,6 +37,7 @@ const LibraryMenuItems = ({
pendingElements, pendingElements,
theme, theme,
setAppState, setAppState,
appState,
libraryReturnUrl, libraryReturnUrl,
library, library,
files, files,
@ -52,6 +56,7 @@ const LibraryMenuItems = ({
theme: AppState["theme"]; theme: AppState["theme"];
files: BinaryFiles; files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
appState: AppState;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library; library: Library;
id: string; id: string;
@ -88,9 +93,7 @@ const LibraryMenuItems = ({
}, [selectedItems, onRemoveFromLibrary, resetLibrary]); }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const device = useDevice();
const isMobile = useDeviceType().isMobile;
const renderLibraryActions = () => { const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length; const itemsSelected = !!selectedItems.length;
const items = itemsSelected const items = itemsSelected
@ -101,7 +104,7 @@ const LibraryMenuItems = ({
: t("buttons.resetLibrary"); : t("buttons.resetLibrary");
return ( return (
<div className="library-actions"> <div className="library-actions">
{(!itemsSelected || !isMobile) && ( {!itemsSelected && (
<ToolButton <ToolButton
key="import" key="import"
type="button" type="button"
@ -186,7 +189,7 @@ const LibraryMenuItems = ({
className="library-actions--publish" className="library-actions--publish"
onClick={onPublish} onClick={onPublish}
> >
{!isMobile && <label>{t("buttons.publishLibrary")}</label>} {!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
{selectedItems.length > 0 && ( {selectedItems.length > 0 && (
<span className="library-actions-counter"> <span className="library-actions-counter">
{selectedItems.length} {selectedItems.length}
@ -195,11 +198,25 @@ const LibraryMenuItems = ({
</ToolButton> </ToolButton>
</Tooltip> </Tooltip>
)} )}
{device.isMobile && (
<div className="library-menu-browse-button--mobile">
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
)}
</div> </div>
); );
}; };
const CELLS_PER_ROW = isMobile ? 4 : 6; const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
const referrer = const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname; libraryReturnUrl || window.location.origin + window.location.pathname;
@ -356,48 +373,185 @@ const LibraryMenuItems = ({
(item) => item.status === "published", (item) => item.status === "published",
); );
return ( const renderLibraryHeader = () => {
<div className="library-menu-items-container"> return (
{showRemoveLibAlert && renderRemoveLibAlert()} <>
<div className="layer-ui__library-header" key="library-header"> <div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()} {renderLibraryActions()}
{isLoading ? ( {device.canDeviceFitSidebar && (
<Spinner /> <>
) : ( <div className="layer-ui__sidebar-lock-button">
<a <SidebarLockButton
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ checked={appState.isLibraryMenuDocked}
window.name || "_blank" onChange={() => {
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${ document
VERSIONS.excalidrawLibrary .querySelector(".layer-ui__wrapper")
}`} ?.classList.add("animate");
target="_excalidraw_libraries" const nextState = !appState.isLibraryMenuDocked;
> setAppState({
{t("labels.libraries")} isLibraryMenuDocked: nextState,
</a> });
)} trackEvent(
</div> "library",
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
</div>
</>
)}
{!device.isMobile && (
<div className="ToolIcon__icon__close">
<button
className="Modal__close"
onClick={() =>
setAppState({
isLibraryOpen: false,
})
}
aria-label={t("buttons.close")}
>
{close}
</button>
</div>
)}
</div>
</>
);
};
const renderLibraryMenuItems = () => {
return (
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
align="start" align="start"
gap={1} gap={1}
style={{
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0,
}}
> >
<> <>
<div className="separator">{t("labels.personalLib")}</div> <div className="separator">
{renderLibrarySection([ {(pendingElements.length > 0 ||
// append pending library item unpublishedItems.length > 0 ||
...(pendingElements.length publishedItems.length > 0) && (
? [{ id: null, elements: pendingElements }] <div>{t("labels.personalLib")}</div>
: []), )}
...unpublishedItems, {isLoading && (
])} <div
style={{
marginLeft: "auto",
marginRight: "1rem",
display: "flex",
alignItems: "center",
fontWeight: "normal",
}}
>
<div style={{ transform: "translateY(2px)" }}>
<Spinner />
</div>
</div>
)}
</div>
{!pendingElements.length && !unpublishedItems.length ? (
<div
style={{
height: 65,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
<div
style={{
margin: ".6rem 0",
fontSize: ".8em",
width: "70%",
textAlign: "center",
}}
>
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)}
</> </>
<> <>
<div className="separator">{t("labels.excalidrawLib")} </div> {(publishedItems.length > 0 ||
(!device.isMobile &&
{renderLibrarySection(publishedItems)} (pendingElements.length > 0 || unpublishedItems.length > 0))) && (
<div className="separator">{t("labels.excalidrawLib")}</div>
)}
{publishedItems.length > 0 ? (
renderLibrarySection(publishedItems)
) : unpublishedItems.length > 0 ? (
<div
style={{
margin: "1rem 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
</div>
) : null}
</> </>
</Stack.Col> </Stack.Col>
);
};
const renderLibraryFooter = () => {
return (
<a
className="library-menu-browse-button"
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
);
};
return (
<div
className="library-menu-items-container"
style={
device.isMobile
? {
minHeight: "200px",
maxHeight: "70vh",
}
: undefined
}
>
{showRemoveLibAlert && renderRemoveLibAlert()}
{renderLibraryHeader()}
{renderLibraryMenuItems()}
{!device.isMobile && renderLibraryFooter()}
</div> </div>
); );
}; };

View file

@ -3,7 +3,7 @@
.excalidraw { .excalidraw {
.library-unit { .library-unit {
align-items: center; align-items: center;
border: 1px solid var(--button-gray-2); border: 1px solid transparent;
display: flex; display: flex;
justify-content: center; justify-content: center;
position: relative; position: relative;
@ -21,10 +21,6 @@
} }
} }
&.theme--dark .library-unit {
border-color: rgb(48, 48, 48);
}
.library-unit__dragger { .library-unit__dragger {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDeviceType } from "../components/App"; import { useDevice } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
@ -67,7 +67,7 @@ export const LibraryUnit = ({
}, [elements, files]); }, [elements, files]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDeviceType().isMobile; const isMobile = useDevice().isMobile;
const adder = isPending && ( const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div> <div className="library-unit__adder">{PLUS_ICON}</div>
); );

View file

@ -43,6 +43,7 @@ type MobileMenuProps = {
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderStats: () => JSX.Element | null;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@ -63,6 +64,7 @@ export const MobileMenu = ({
showThemeBtn, showThemeBtn,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderStats,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
@ -184,6 +186,7 @@ export const MobileMenu = ({
return ( return (
<> <>
{!viewModeEnabled && renderToolbar()} {!viewModeEnabled && renderToolbar()}
{renderStats()}
<div <div
className="App-bottom-bar" className="App-bottom-bar"
style={{ style={{
@ -202,20 +205,11 @@ export const MobileMenu = ({
{appState.collaborators.size > 0 && ( {appState.collaborators.size > 0 && (
<fieldset> <fieldset>
<legend>{t("labels.collaborators")}</legend> <legend>{t("labels.collaborators")}</legend>
<UserList mobile> <UserList
{Array.from(appState.collaborators) mobile
// Collaborator is either not initialized or is actually the current user. collaborators={appState.collaborators}
.filter( actionManager={actionManager}
([_, client]) => Object.keys(client).length !== 0, />
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</React.Fragment>
))}
</UserList>
</fieldset> </fieldset>
)} )}
</Stack.Col> </Stack.Col>

View file

@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useExcalidrawContainer, useDeviceType } from "./App"; import { useExcalidrawContainer, useDevice } from "./App";
import { AppState } from "../types"; import { AppState } from "../types";
import { THEME } from "../constants"; import { THEME } from "../constants";
@ -59,17 +59,17 @@ export const Modal = (props: {
const useBodyRoot = (theme: AppState["theme"]) => { const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null); const [div, setDiv] = useState<HTMLDivElement | null>(null);
const deviceType = useDeviceType(); const device = useDevice();
const isMobileRef = useRef(deviceType.isMobile); const isMobileRef = useRef(device.isMobile);
isMobileRef.current = deviceType.isMobile; isMobileRef.current = device.isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer(); const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
div.classList.toggle("excalidraw--mobile", deviceType.isMobile); div.classList.toggle("excalidraw--mobile", device.isMobile);
} }
}, [div, deviceType.isMobile]); }, [div, device.isMobile]);
useLayoutEffect(() => { useLayoutEffect(() => {
const isDarkTheme = const isDarkTheme =

View file

@ -1,6 +1,8 @@
import React, { useLayoutEffect, useRef, useEffect } from "react"; import React, { useLayoutEffect, useRef, useEffect } from "react";
import "./Popover.scss"; import "./Popover.scss";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { queryFocusableElements } from "../utils";
import { KEYS } from "../keys";
type Props = { type Props = {
top?: number; top?: number;
@ -27,6 +29,41 @@ export const Popover = ({
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const container = popoverRef.current;
useEffect(() => {
if (!container) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(container);
const { activeElement } = document;
const currentIndex = focusableElements.findIndex(
(element) => element === activeElement,
);
if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1].focus();
event.preventDefault();
event.stopImmediatePropagation();
} else if (
currentIndex === focusableElements.length - 1 &&
!event.shiftKey
) {
focusableElements[0].focus();
event.preventDefault();
event.stopImmediatePropagation();
}
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [container]);
// ensure the popover doesn't overflow the viewport // ensure the popover doesn't overflow the viewport
useLayoutEffect(() => { useLayoutEffect(() => {
if (fitInViewport && popoverRef.current) { if (fitInViewport && popoverRef.current) {

View file

@ -0,0 +1,22 @@
@import "../css/variables.module";
.excalidraw {
.layer-ui__sidebar-lock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
}
.ToolIcon_type_floating .side_lock_icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
background-color: var(--color-primary);
}
}
}

View file

@ -0,0 +1,46 @@
import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { t } from "../i18n";
import { Tooltip } from "./Tooltip";
import "./SidebarLockButton.scss";
type SidebarLockIconProps = {
checked: boolean;
onChange?(): void;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarLockButton = (props: SidebarLockIconProps) => {
return (
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
</div>{" "}
</label>{" "}
</Tooltip>
);
};

View file

@ -41,6 +41,7 @@ const ColStack = ({
align, align,
justifyContent, justifyContent,
className, className,
style,
}: StackProps) => { }: StackProps) => {
return ( return (
<div <div
@ -49,6 +50,7 @@ const ColStack = ({
"--gap": gap, "--gap": gap,
justifyItems: align, justifyItems: align,
justifyContent, justifyContent,
...style,
}} }}
> >
{children} {children}

View file

@ -7,6 +7,7 @@
right: 12px; right: 12px;
font-size: 12px; font-size: 12px;
z-index: 10; z-index: 10;
pointer-events: all;
h3 { h3 {
margin: 0 24px 8px 0; margin: 0 24px 8px 0;

View file

@ -2,7 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDeviceType } from "../components/App"; import { useDevice } from "../components/App";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons"; import { close } from "./icons";
@ -16,16 +16,13 @@ export const Stats = (props: {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const deviceType = useDeviceType(); const device = useDevice();
const boundingBox = getCommonBounds(props.elements); const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState); const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements); const selectedBoundingBox = getCommonBounds(selectedElements);
if (device.isMobile && props.appState.openMenu) {
if (deviceType.isMobile && props.appState.openMenu) {
return null; return null;
} }
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>

View file

@ -2,6 +2,9 @@
.excalidraw { .excalidraw {
.Toast { .Toast {
$closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem;
animation: fade-in 0.5s; animation: fade-in 0.5s;
background-color: var(--button-gray-1); background-color: var(--button-gray-1);
border-radius: 4px; border-radius: 4px;
@ -15,11 +18,24 @@
text-align: center; text-align: center;
width: 300px; width: 300px;
z-index: 999999; z-index: 999999;
}
.Toast__message { .Toast__message {
color: var(--popup-text-color); padding: 0 $closeButtonSize + ($closeButtonPadding);
white-space: pre-wrap; color: var(--popup-text-color);
white-space: pre-wrap;
}
.close {
position: absolute;
top: 0;
right: 0;
padding: $closeButtonPadding;
.ToolIcon__icon {
width: $closeButtonSize;
height: $closeButtonSize;
}
}
} }
@keyframes fade-in { @keyframes fade-in {

View file

@ -1,34 +1,59 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { TOAST_TIMEOUT } from "../constants"; import { close } from "./icons";
import "./Toast.scss"; import "./Toast.scss";
import { ToolButton } from "./ToolButton";
const DEFAULT_TOAST_TIMEOUT = 5000;
export const Toast = ({ export const Toast = ({
message, message,
clearToast, clearToast,
closable = false,
// To prevent autoclose, pass duration as Infinity
duration = DEFAULT_TOAST_TIMEOUT,
}: { }: {
message: string; message: string;
clearToast: () => void; clearToast: () => void;
closable?: boolean;
duration?: number;
}) => { }) => {
const timerRef = useRef<number>(0); const timerRef = useRef<number>(0);
const shouldAutoClose = duration !== Infinity;
const scheduleTimeout = useCallback( const scheduleTimeout = useCallback(() => {
() => if (!shouldAutoClose) {
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)), return;
[clearToast], }
); timerRef.current = window.setTimeout(() => clearToast(), duration);
}, [clearToast, duration, shouldAutoClose]);
useEffect(() => { useEffect(() => {
if (!shouldAutoClose) {
return;
}
scheduleTimeout(); scheduleTimeout();
return () => clearTimeout(timerRef.current); return () => clearTimeout(timerRef.current);
}, [scheduleTimeout, message]); }, [scheduleTimeout, message, duration, shouldAutoClose]);
const onMouseEnter = shouldAutoClose
? () => clearTimeout(timerRef?.current)
: undefined;
const onMouseLeave = shouldAutoClose ? scheduleTimeout : undefined;
return ( return (
<div <div
className="Toast" className="Toast"
onMouseEnter={() => clearTimeout(timerRef?.current)} onMouseEnter={onMouseEnter}
onMouseLeave={scheduleTimeout} onMouseLeave={onMouseLeave}
> >
<p className="Toast__message">{message}</p> <p className="Toast__message">{message}</p>
{closable && (
<ToolButton
icon={close}
aria-label="close"
type="icon"
onClick={clearToast}
className="close"
/>
)}
</div> </div>
); );
}; };

View file

@ -1,26 +1,5 @@
@import "open-color/open-color.scss"; @import "open-color/open-color.scss";
@import "../css/variables.module";
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
.excalidraw { .excalidraw {
.App-toolbar-container { .App-toolbar-container {

View file

@ -2,17 +2,51 @@ import "./UserList.scss";
import React from "react"; import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { ActionManager } from "../actions/manager";
type UserListProps = { export const UserList: React.FC<{
children: React.ReactNode;
className?: string; className?: string;
mobile?: boolean; mobile?: boolean;
}; collaborators: AppState["collaborators"];
actionManager: ActionManager;
}> = ({ className, mobile, collaborators, actionManager }) => {
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
collaborator,
);
});
const avatars =
uniqueCollaborators.size > 0 &&
Array.from(uniqueCollaborators)
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, collaborator]) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
collaborator,
]);
return mobile ? (
<Tooltip
label={collaborator.username || "Unknown user"}
key={clientId}
>
{avatarJSX}
</Tooltip>
) : (
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
);
});
export const UserList = ({ children, className, mobile }: UserListProps) => {
return ( return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}> <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{children} {avatars}
</div> </div>
); );
}; };

View file

@ -116,7 +116,6 @@ export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
@ -155,9 +154,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
}, },
}; };
// breakpoints
// -----------------------------------------------------------------------------
// sm screen
export const MQ_SM_MAX_WIDTH = 640;
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730; export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500; export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;

View file

@ -1,42 +0,0 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};

View file

@ -350,7 +350,6 @@
align-items: flex-start; align-items: flex-start;
cursor: default; cursor: default;
pointer-events: none !important; pointer-events: none !important;
z-index: 100;
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: 0.25rem; left: 0.25rem;
@ -391,6 +390,7 @@
.App-menu__left { .App-menu__left {
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-island);
} }
.dropdown-select { .dropdown-select {
@ -449,6 +449,7 @@
bottom: 30px; bottom: 30px;
transform: translateX(-50%); transform: translateX(-50%);
padding: 10px 20px; padding: 10px 20px;
pointer-events: all;
} }
.help-icon { .help-icon {
@ -567,6 +568,22 @@
display: none; display: none;
} }
} }
// use custom, minimalistic scrollbar
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-thumb {
background: var(--button-gray-2);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--button-gray-3);
}
::-webkit-scrollbar-thumb:active {
background: var(--button-gray-2);
}
} }
.ErrorSplash.excalidraw { .ErrorSplash.excalidraw {

View file

@ -6,8 +6,32 @@
} }
} }
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)"; $theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px";
:export { :export {
themeFilter: unquote($theme-filter); themeFilter: unquote($theme-filter);
rightSidebarWidth: unquote($right-sidebar-width);
} }

View file

@ -1,5 +1,4 @@
import { import {
FileWithHandle,
fileOpen as _fileOpen, fileOpen as _fileOpen,
fileSave as _fileSave, fileSave as _fileSave,
FileSystemHandle, FileSystemHandle,
@ -26,13 +25,9 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
description: string; description: string;
multiple?: M; multiple?: M;
}): Promise< }): Promise<M extends false | undefined ? File : File[]> => {
M extends false | undefined ? FileWithHandle : FileWithHandle[]
> => {
// an unsafe TS hack, alas not much we can do AFAIK // an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined type RetType = M extends false | undefined ? File : File[];
? FileWithHandle
: FileWithHandle[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]); mimeTypes.push(MIME_TYPES[type]);

View file

@ -98,7 +98,12 @@ export const loadFromJSON = async (
// gets resolved. Else, iOS users cannot open `.excalidraw` files. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"], // extensions: ["json", "excalidraw", "png", "svg"],
}); });
return loadFromBlob(await normalizeFile(file), localAppState, localElements); return loadFromBlob(
await normalizeFile(file),
localAppState,
localElements,
file.handle,
);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {

View file

@ -283,6 +283,11 @@ export const restoreAppState = (
value: appState.zoom as NormalizedZoomValue, value: appState.zoom as NormalizedZoomValue,
} }
: appState.zoom || defaultAppState.zoom, : appState.zoom || defaultAppState.zoom,
// when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state.
isLibraryOpen: nextAppState.isLibraryMenuDocked
? nextAppState.isLibraryOpen
: false,
}; };
}; };

View file

@ -115,6 +115,9 @@ describe("textWysiwyg", () => {
height: textSize, height: textSize,
containerId: container.id, containerId: container.id,
}); });
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
h.elements = [container, text]; h.elements = [container, text];

View file

@ -8,10 +8,12 @@ import {
ExcalidrawElement, ExcalidrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "../../element/types"; } from "../../element/types";
import { getSceneVersion } from "../../packages/excalidraw/index"; import {
getSceneVersion,
restoreElements,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types"; import { Collaborator, Gesture } from "../../types";
import { import {
getFrame,
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
withBatchedUpdates, withBatchedUpdates,
@ -47,11 +49,9 @@ import {
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants"; import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { trackEvent } from "../../analytics";
import { import {
encodeFilesForUpload, encodeFilesForUpload,
FileManager, FileManager,
@ -70,52 +70,45 @@ import {
import { decryptData } from "../../data/encryption"; import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai";
import { jotaiStore } from "../../jotai";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
export const isCollaboratingAtom = atom(false);
interface CollabState { interface CollabState {
modalIsShown: boolean;
errorMessage: string; errorMessage: string;
username: string; username: string;
userState: UserIdleState;
activeRoomLink: string; activeRoomLink: string;
} }
type CollabInstance = InstanceType<typeof CollabWrapper>; type CollabInstance = InstanceType<typeof Collab>;
export interface CollabAPI { export interface CollabAPI {
/** function so that we can access the latest value from stale callbacks */ /** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean; isCollaborating: () => boolean;
username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"]; onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"]; startCollaboration: CollabInstance["startCollaboration"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"]; stopCollaboration: CollabInstance["stopCollaboration"];
syncElements: CollabInstance["syncElements"]; syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void; setUsername: (username: string) => void;
} }
interface Props { interface PublicProps {
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
onRoomClose?: () => void;
} }
const { type Props = PublicProps & { modalIsShown: boolean };
Context: CollabContext,
Consumer: CollabContextConsumer,
Provider: CollabContextProvider,
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
export { CollabContext, CollabContextConsumer }; class Collab extends PureComponent<Props, CollabState> {
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
fileManager: FileManager; fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: Props["excalidrawAPI"];
activeIntervalId: number | null; activeIntervalId: number | null;
idleTimeoutId: number | null; idleTimeoutId: number | null;
// marked as private to ensure we don't change it outside this class
private _isCollaborating: boolean = false;
private socketInitializationTimer?: number; private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>(); private collaborators = new Map<string, Collaborator>();
@ -123,10 +116,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
modalIsShown: false,
errorMessage: "", errorMessage: "",
username: importUsernameFromLocalStorage() || "", username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "", activeRoomLink: "",
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
@ -164,6 +155,18 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.addEventListener(EVENT.UNLOAD, this.onUnload); window.addEventListener(EVENT.UNLOAD, this.onUnload);
const collabAPI: CollabAPI = {
isCollaborating: this.isCollaborating,
onPointerUpdate: this.onPointerUpdate,
startCollaboration: this.startCollaboration,
syncElements: this.syncElements,
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
stopCollaboration: this.stopCollaboration,
setUsername: this.setUsername,
};
jotaiStore.set(collabAPIAtom, collabAPI);
if ( if (
process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT process.env.NODE_ENV === ENV.DEVELOPMENT
@ -196,7 +199,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
} }
isCollaborating = () => this._isCollaborating; isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
private setIsCollaborating = (isCollaborating: boolean) => {
jotaiStore.set(isCollaboratingAtom, isCollaborating);
};
private onUnload = () => { private onUnload = () => {
this.destroySocketClient({ isUnload: true }); this.destroySocketClient({ isUnload: true });
@ -208,7 +215,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
); );
if ( if (
this._isCollaborating && this.isCollaborating() &&
(this.fileManager.shouldPreventUnload(syncableElements) || (this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements)) !isSavedToFirebase(this.portal, syncableElements))
) { ) {
@ -252,12 +259,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
}; };
openPortal = async () => { stopCollaboration = (keepRemoteState = true) => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
return this.initializeSocketClient(null);
};
closePortal = () => {
this.queueBroadcastAllElements.cancel(); this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel(); this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel(); this.loadImageFiles.cancel();
@ -267,16 +269,26 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.excalidrawAPI.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
), ),
); );
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
if (!keepRemoteState) {
LocalData.fileStorage.reset();
this.destroySocketClient();
} else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
// hack to ensure that we prefer we disregard any new browser state // hack to ensure that we prefer we disregard any new browser state
// that could have been saved in other tabs while we were collaborating // that could have been saved in other tabs while we were collaborating
resetBrowserStateVersions(); resetBrowserStateVersions();
window.history.pushState({}, APP_NAME, window.location.origin); window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient(); this.destroySocketClient();
trackEvent("share", "room closed");
this.props.onRoomClose?.(); LocalData.fileStorage.reset();
const elements = this.excalidrawAPI const elements = this.excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
@ -295,20 +307,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
private destroySocketClient = (opts?: { isUnload: boolean }) => { private destroySocketClient = (opts?: { isUnload: boolean }) => {
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
if (!opts?.isUnload) { if (!opts?.isUnload) {
this.setIsCollaborating(false);
this.setState({
activeRoomLink: "",
});
this.collaborators = new Map(); this.collaborators = new Map();
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
collaborators: this.collaborators, collaborators: this.collaborators,
}); });
this.setState({
activeRoomLink: "",
});
this._isCollaborating = false;
LocalData.resumeSave("collaboration"); LocalData.resumeSave("collaboration");
} }
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
}; };
private fetchImageFilesFromFirebase = async (scene: { private fetchImageFilesFromFirebase = async (scene: {
@ -349,7 +361,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
}; };
private initializeSocketClient = async ( private fallbackInitializationHandler: null | (() => any) = null;
startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string }, existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => { ): Promise<ImportedDataState | null> => {
if (this.portal.socket) { if (this.portal.socket) {
@ -372,13 +386,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
const scenePromise = resolvablePromise<ImportedDataState | null>(); const scenePromise = resolvablePromise<ImportedDataState | null>();
this._isCollaborating = true; this.setIsCollaborating(true);
LocalData.pauseSave("collaboration"); LocalData.pauseSave("collaboration");
const { default: socketIOClient } = await import( const { default: socketIOClient } = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client" /* webpackChunkName: "socketIoClient" */ "socket.io-client"
); );
const fallbackInitializationHandler = () => {
this.initializeRoom({
roomLinkData: existingRoomLinkData,
fetchScene: true,
}).then((scene) => {
scenePromise.resolve(scene);
});
};
this.fallbackInitializationHandler = fallbackInitializationHandler;
try { try {
const socketServerData = await getCollabServer(); const socketServerData = await getCollabServer();
@ -391,6 +415,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
roomId, roomId,
roomKey, roomKey,
); );
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
this.setState({ errorMessage: error.message }); this.setState({ errorMessage: error.message });
@ -419,13 +445,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// fallback in case you're not alone in the room but still don't receive // fallback in case you're not alone in the room but still don't receive
// initial SCENE_INIT message // initial SCENE_INIT message
this.socketInitializationTimer = window.setTimeout(() => { this.socketInitializationTimer = window.setTimeout(
this.initializeRoom({ fallbackInitializationHandler,
roomLinkData: existingRoomLinkData, INITIAL_SCENE_UPDATE_TIMEOUT,
fetchScene: true, );
});
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
// All socket listeners are moving to Portal // All socket listeners are moving to Portal
this.portal.socket.on( this.portal.socket.on(
@ -530,6 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
| { fetchScene: false; roomLinkData?: null }) => { | { fetchScene: false; roomLinkData?: null }) => {
clearTimeout(this.socketInitializationTimer!); clearTimeout(this.socketInitializationTimer!);
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
if (fetchScene && roomLinkData && this.portal.socket) { if (fetchScene && roomLinkData && this.portal.socket) {
this.excalidrawAPI.resetScene(); this.excalidrawAPI.resetScene();
@ -567,6 +596,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
const localElements = this.getSceneElementsIncludingDeleted(); const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
remoteElements = restoreElements(remoteElements, null);
const reconciledElements = _reconcileElements( const reconciledElements = _reconcileElements(
localElements, localElements,
remoteElements, remoteElements,
@ -672,19 +703,17 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
setCollaborators(sockets: string[]) { setCollaborators(sockets: string[]) {
this.setState((state) => { const collaborators: InstanceType<typeof Collab>["collaborators"] =
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] = new Map();
new Map(); for (const socketId of sockets) {
for (const socketId of sockets) { if (this.collaborators.has(socketId)) {
if (this.collaborators.has(socketId)) { collaborators.set(socketId, this.collaborators.get(socketId)!);
collaborators.set(socketId, this.collaborators.get(socketId)!); } else {
} else { collaborators.set(socketId, {});
collaborators.set(socketId, {});
}
} }
this.collaborators = collaborators; }
this.excalidrawAPI.updateScene({ collaborators }); this.collaborators = collaborators;
}); this.excalidrawAPI.updateScene({ collaborators });
} }
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
@ -713,7 +742,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
); );
onIdleStateChange = (userState: UserIdleState) => { onIdleStateChange = (userState: UserIdleState) => {
this.setState({ userState });
this.portal.broadcastIdleChange(userState); this.portal.broadcastIdleChange(userState);
}; };
@ -747,18 +775,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setLastBroadcastedOrReceivedSceneVersion(newVersion); this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
}, SYNC_FULL_SCENE_INTERVAL_MS); }, SYNC_FULL_SCENE_INTERVAL_MS);
queueSaveToFirebase = throttle(() => { queueSaveToFirebase = throttle(
if (this.portal.socketInitialized) { () => {
this.saveCollabRoomToFirebase( if (this.portal.socketInitialized) {
getSyncableElements( this.saveCollabRoomToFirebase(
this.excalidrawAPI.getSceneElementsIncludingDeleted(), getSyncableElements(
), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
); ),
} );
}, SYNC_FULL_SCENE_INTERVAL_MS); }
},
SYNC_FULL_SCENE_INTERVAL_MS,
{ leading: false },
);
handleClose = () => { handleClose = () => {
this.setState({ modalIsShown: false }); jotaiStore.set(collabDialogShownAtom, false);
}; };
setUsername = (username: string) => { setUsername = (username: string) => {
@ -770,35 +802,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
saveUsernameToLocalStorage(username); saveUsernameToLocalStorage(username);
}; };
onCollabButtonClick = () => {
this.setState({
modalIsShown: true,
});
};
/** PRIVATE. Use `this.getContextValue()` instead. */
private contextValue: CollabAPI | null = null;
/** Getter of context value. Returned object is stable. */
getContextValue = (): CollabAPI => {
if (!this.contextValue) {
this.contextValue = {} as CollabAPI;
}
this.contextValue.isCollaborating = this.isCollaborating;
this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.syncElements = this.syncElements;
this.contextValue.fetchImageFilesFromFirebase =
this.fetchImageFilesFromFirebase;
this.contextValue.setUsername = this.setUsername;
return this.contextValue;
};
render() { render() {
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state; const { username, errorMessage, activeRoomLink } = this.state;
const { modalIsShown } = this.props;
return ( return (
<> <>
@ -808,8 +815,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
activeRoomLink={activeRoomLink} activeRoomLink={activeRoomLink}
username={username} username={username}
onUsernameChange={this.onUsernameChange} onUsernameChange={this.onUsernameChange}
onRoomCreate={this.openPortal} onRoomCreate={() => this.startCollaboration(null)}
onRoomDestroy={this.closePortal} onRoomDestroy={this.stopCollaboration}
setErrorMessage={(errorMessage) => { setErrorMessage={(errorMessage) => {
this.setState({ errorMessage }); this.setState({ errorMessage });
}} }}
@ -822,11 +829,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
onClose={() => this.setState({ errorMessage: "" })} onClose={() => this.setState({ errorMessage: "" })}
/> />
)} )}
<CollabContextProvider
value={{
api: this.getContextValue(),
}}
/>
</> </>
); );
} }
@ -834,7 +836,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
declare global { declare global {
interface Window { interface Window {
collab: InstanceType<typeof CollabWrapper>; collab: InstanceType<typeof Collab>;
} }
} }
@ -845,4 +847,11 @@ if (
window.collab = window.collab || ({} as Window["collab"]); window.collab = window.collab || ({} as Window["collab"]);
} }
export default CollabWrapper; const _Collab: React.FC<PublicProps> = (props) => {
const [collabDialogShown] = useAtom(collabDialogShownAtom);
return <Collab {...props} modalIsShown={collabDialogShown} />;
};
export default _Collab;
export type TCollabClass = Collab;

View file

@ -4,7 +4,7 @@ import {
SocketUpdateDataSource, SocketUpdateDataSource,
} from "../data"; } from "../data";
import CollabWrapper from "./CollabWrapper"; import { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { import {
@ -20,14 +20,14 @@ import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../data/encryption"; import { encryptData } from "../../data/encryption";
class Portal { class Portal {
collab: CollabWrapper; collab: TCollabClass;
socket: SocketIOClient.Socket | null = null; socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null; roomId: string | null = null;
roomKey: string | null = null; roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map(); broadcastedElementVersions: Map<string, number> = new Map();
constructor(collab: CollabWrapper) { constructor(collab: TCollabClass) {
this.collab = collab; this.collab = collab;
} }

View file

@ -14,6 +14,8 @@ import { t } from "../../i18n";
import "./RoomDialog.scss"; import "./RoomDialog.scss";
import Stack from "../../components/Stack"; import Stack from "../../components/Stack";
import { AppState } from "../../types"; import { AppState } from "../../types";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
const getShareIcon = () => { const getShareIcon = () => {
const navigator = window.navigator as any; const navigator = window.navigator as any;
@ -95,7 +97,10 @@ const RoomDialog = ({
title={t("roomDialog.button_startSession")} title={t("roomDialog.button_startSession")}
aria-label={t("roomDialog.button_startSession")} aria-label={t("roomDialog.button_startSession")}
showAriaLabel={true} showAriaLabel={true}
onClick={onRoomCreate} onClick={() => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
onRoomCreate();
}}
/> />
</div> </div>
</> </>
@ -160,7 +165,10 @@ const RoomDialog = ({
title={t("roomDialog.button_stopSession")} title={t("roomDialog.button_stopSession")}
aria-label={t("roomDialog.button_stopSession")} aria-label={t("roomDialog.button_stopSession")}
showAriaLabel={true} showAriaLabel={true}
onClick={onRoomDestroy} onClick={() => {
trackEvent("share", "room closed");
onRoomDestroy();
}}
/> />
</div> </div>
</> </>

View file

@ -134,9 +134,16 @@ export type SocketUpdateData =
_brand: "socketUpdateData"; _brand: "socketUpdateData";
}; };
const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
export const isCollaborationLink = (link: string) => {
const hash = new URL(link).hash;
return RE_COLLAB_LINK.test(hash);
};
export const getCollaborationLinkData = (link: string) => { export const getCollaborationLinkData = (link: string) => {
const hash = new URL(link).hash; const hash = new URL(link).hash;
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/); const match = hash.match(RE_COLLAB_LINK);
if (match && match[2].length !== 22) { if (match && match[2].length !== 22) {
window.alert(t("alerts.invalidEncryptionKey")); window.alert(t("alerts.invalidEncryptionKey"));
return null; return null;

View file

@ -1,5 +1,5 @@
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { ErrorDialog } from "../components/ErrorDialog"; import { ErrorDialog } from "../components/ErrorDialog";
@ -18,7 +18,7 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { Language, t } from "../i18n"; import { t } from "../i18n";
import { import {
Excalidraw, Excalidraw,
defaultLang, defaultLang,
@ -45,20 +45,26 @@ import {
STORAGE_KEYS, STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT, SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants"; } from "./app_constants";
import CollabWrapper, { import Collab, {
CollabAPI, CollabAPI,
CollabContext, collabAPIAtom,
CollabContextConsumer, collabDialogShownAtom,
} from "./collab/CollabWrapper"; isCollaboratingAtom,
} from "./collab/Collab";
import { LanguageList } from "./components/LanguageList"; import { LanguageList } from "./components/LanguageList";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data"; import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import { import {
getLibraryItemsFromStorage, getLibraryItemsFromStorage,
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { restoreAppState, RestoredDataState } from "../data/restore"; import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { shield } from "../components/icons"; import { shield } from "../components/icons";
@ -72,6 +78,9 @@ import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData"; import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx"; import clsx from "clsx";
import { Provider, useAtom } from "jotai";
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
const isExcalidrawPlusSignedUser = document.cookie.includes( const isExcalidrawPlusSignedUser = document.cookie.includes(
@ -80,11 +89,7 @@ const isExcalidrawPlusSignedUser = document.cookie.includes(
const languageDetector = new LanguageDetector(); const languageDetector = new LanguageDetector();
languageDetector.init({ languageDetector.init({
languageUtils: { languageUtils: {},
formatLanguageCode: (langCode: Language["code"]) => langCode,
isWhitelisted: () => true,
},
checkWhitelist: false,
}); });
const initializeScene = async (opts: { const initializeScene = async (opts: {
@ -174,7 +179,7 @@ const initializeScene = async (opts: {
if (roomLinkData) { if (roomLinkData) {
return { return {
scene: await opts.collabAPI.initializeSocketClient(roomLinkData), scene: await opts.collabAPI.startCollaboration(roomLinkData),
isExternalScene: true, isExternalScene: true,
id: roomLinkData.roomId, id: roomLinkData.roomId,
key: roomLinkData.roomKey, key: roomLinkData.roomKey,
@ -246,7 +251,11 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] = const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>(); useCallbackRefState<ExcalidrawImperativeAPI>();
const collabAPI = useContext(CollabContext)?.api; const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
useHandleLibrary({ useHandleLibrary({
excalidrawAPI, excalidrawAPI,
@ -324,21 +333,44 @@ const ExcalidrawWrapper = () => {
} }
}; };
initializeScene({ collabAPI }).then((data) => { initializeScene({ collabAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
initialStatePromiseRef.current.promise.resolve(data.scene);
initialStatePromiseRef.current.promise.resolve({
...data.scene,
// at this point the state may have already been updated (e.g. when
// collaborating, we may have received updates from other clients)
appState: restoreAppState(
data.scene?.appState,
excalidrawAPI.getAppState(),
),
elements: reconcileElements(
data.scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
});
}); });
const onHashChange = async (event: HashChangeEvent) => { const onHashChange = async (event: HashChangeEvent) => {
event.preventDefault(); event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl(); const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) { if (!libraryUrlTokens) {
if (
collabAPI.isCollaborating() &&
!isCollaborationLink(window.location.href)
) {
collabAPI.stopCollaboration(false);
}
excalidrawAPI.updateScene({ appState: { isLoading: true } });
initializeScene({ collabAPI }).then((data) => { initializeScene({ collabAPI }).then((data) => {
loadImages(data); loadImages(data);
if (data.scene) { if (data.scene) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...data.scene, ...data.scene,
appState: restoreAppState(data.scene.appState, null), ...restore(data.scene, null, null),
commitToHistory: true,
}); });
} }
}); });
@ -479,19 +511,17 @@ const ExcalidrawWrapper = () => {
if (excalidrawAPI) { if (excalidrawAPI) {
let didChange = false; let didChange = false;
let pendingImageElement = appState.pendingImageElement;
const elements = excalidrawAPI const elements = excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if ( if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element) LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) { ) {
didChange = true; const newElement = newElementWith(element, { status: "saved" });
const newEl = newElementWith(element, { status: "saved" }); if (newElement !== element) {
if (pendingImageElement === element) { didChange = true;
pendingImageElement = newEl;
} }
return newEl; return newElement;
} }
return element; return element;
}); });
@ -499,9 +529,6 @@ const ExcalidrawWrapper = () => {
if (didChange) { if (didChange) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements, elements,
appState: {
pendingImageElement,
},
}); });
} }
} }
@ -645,23 +672,19 @@ const ExcalidrawWrapper = () => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
}; };
const onRoomClose = useCallback(() => {
LocalData.fileStorage.reset();
}, []);
return ( return (
<div <div
style={{ height: "100%" }} style={{ height: "100%" }}
className={clsx("excalidraw-app", { className={clsx("excalidraw-app", {
"is-collaborating": collabAPI?.isCollaborating(), "is-collaborating": isCollaborating,
})} })}
> >
<Excalidraw <Excalidraw
ref={excalidrawRefCallback} ref={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
onCollabButtonClick={collabAPI?.onCollabButtonClick} onCollabButtonClick={() => setCollabDialogShown(true)}
isCollaborating={collabAPI?.isCollaborating()} isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{ UIOptions={{
canvasActions: { canvasActions: {
@ -695,12 +718,7 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange} onLibraryChange={onLibraryChange}
autoFocus={true} autoFocus={true}
/> />
{excalidrawAPI && ( {excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
<CollabWrapper
excalidrawAPI={excalidrawAPI}
onRoomClose={onRoomClose}
/>
)}
{errorMessage && ( {errorMessage && (
<ErrorDialog <ErrorDialog
message={errorMessage} message={errorMessage}
@ -714,9 +732,9 @@ const ExcalidrawWrapper = () => {
const ExcalidrawApp = () => { const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<CollabContextConsumer> <Provider unstable_createStore={() => jotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawWrapper />
</CollabContextConsumer> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
); );
}; };

View file

@ -1,4 +1,27 @@
import { unstable_createStore } from "jotai"; import { unstable_createStore, useAtom, WritableAtom } from "jotai";
import { useLayoutEffect } from "react";
export const jotaiScope = Symbol(); export const jotaiScope = Symbol();
export const jotaiStore = unstable_createStore(); export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = <
T extends unknown,
A extends WritableAtom<T, T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};

View file

@ -120,7 +120,13 @@
"lockAll": "Lock all", "lockAll": "Lock all",
"unlockAll": "Unlock all" "unlockAll": "Unlock all"
}, },
"statusPublished": "Published" "statusPublished": "Published",
"sidebarLock": "Keep sidebar open"
},
"library": {
"noItems": "No items added yet...",
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
}, },
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",

View file

@ -17,6 +17,10 @@ Please add the latest change on the top under the correct section.
#### Features #### Features
- Add `[UIOptions.dockedSidebarBreakpoint]`(https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#dockedSidebarBreakpoint) to customize at which point to break from the docked sidebar [#5274](https://github.com/excalidraw/excalidraw/pull/5274).
- Added support for supplying user `id` in the Collaborator object (see `collaborators` in [`updateScene()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene)), which will be used to deduplicate users when rendering collaborator avatar list. Cursors will still be rendered for every user. [#5309](https://github.com/excalidraw/excalidraw/pull/5309)
- Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215). - Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215).
- Export [`sceneCoordsToViewportCoords`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) and [`viewportCoordsToSceneCoords`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) utilities [#5187](https://github.com/excalidraw/excalidraw/pull/5187). - Export [`sceneCoordsToViewportCoords`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) and [`viewportCoordsToSceneCoords`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) utilities [#5187](https://github.com/excalidraw/excalidraw/pull/5187).

View file

@ -1,3 +1,7 @@
#### Note
⚠️ ⚠️ ⚠️ You are viewing the docs for the **next** release, in case you want to check the docs for the stable release, you can view it [here](https://www.npmjs.com/package/@excalidraw/excalidraw).
### Excalidraw ### Excalidraw
Excalidraw exported as a component to directly embed in your projects. Excalidraw exported as a component to directly embed in your projects.
@ -26,7 +30,7 @@ If you want to load assets from a different path you can set a variable `window.
#### Note #### Note
**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).** **If you don't want to wait for the next stable release and try out the unreleased changes you can use `@excalidraw/excalidraw@next`.**
### Demo ### Demo
@ -42,7 +46,7 @@ If you are using a Web bundler (for instance, Webpack), you can import it as an
```js ```js
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
import Excalidraw from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import InitialData from "./initialData"; import InitialData from "./initialData";
import "./styles.scss"; import "./styles.scss";
@ -322,7 +326,7 @@ const App = () => {
className: "excalidraw-wrapper", className: "excalidraw-wrapper",
ref: excalidrawWrapperRef, ref: excalidrawWrapperRef,
}, },
React.createElement(Excalidraw.default, { React.createElement(ExcalidrawLib.Excalidraw, {
initialData: InitialData, initialData: InitialData,
onChange: (elements, state) => onChange: (elements, state) =>
console.log("Elements :", elements, "State : ", state), console.log("Elements :", elements, "State : ", state),
@ -377,7 +381,7 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| [`onChange`](#onChange) | Function | | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw elements and the current app state. | | [`onChange`](#onChange) | Function | | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw elements and the current app state. |
| [`initialData`](#initialData) | <pre>{elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState<a> } </pre> | null | The initial data with which app loads. | | [`initialData`](#initialData) | <pre>{elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState<a> } </pre> | null | The initial data with which app loads. |
| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) &#124; [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) &#124; [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) &#124; <pre>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317">resolvablePromise</a> } }</pre> | | Ref to be passed to Excalidraw | | [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) &#124; [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) &#124; [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) &#124; <pre>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317">resolvablePromise</a> } }</pre> | | Ref to be passed to Excalidraw |
| [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked | | [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked |
| [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode | | [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode |
@ -399,7 +403,9 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr
| [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> | | The callback if supplied is triggered when the library is updated and receives the library items. | | [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> | | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load | | [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise<string>` | Allows you to override `id` generation for files added on canvas | | [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise<string>` | Allows you to override `id` generation for files added on canvas |
| [`onLinkOpen`](#onLinkOpen) | <pre>(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">NonDeletedExcalidrawElement</a>, event: CustomEvent) </pre> | | This prop if passed will be triggered when link of an element is clicked | | [`onLinkOpen`](#onLinkOpen) | <pre>(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">NonDeletedExcalidrawElement</a>, event: CustomEvent) </pre> | | This prop if passed will be triggered when link of an element is clicked. |
| [`onPointerDown`](#onPointerDown) | <pre>(activeTool: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L93"> AppState["activeTool"]</a>, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L365">PointerDownState</a>) => void</pre> | | This prop if passed gets triggered on pointer down evenets |
| [`onScrollChange`](#onScrollChange) | (scrollX: number, scrollY: number) | | This prop if passed gets triggered when scrolling the canvas. |
### Dimensions of Excalidraw ### Dimensions of Excalidraw
@ -413,9 +419,9 @@ Every time component updates, this callback if passed will get triggered and has
(excalidrawElements, appState, files) => void; (excalidrawElements, appState, files) => void;
``` ```
1.`excalidrawElements`: Array of [excalidrawElements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) in the scene. 1.`excalidrawElements`: Array of [excalidrawElements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106) in the scene.
2.`appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) of the scene. 2.`appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) of the scene.
3. `files`: The [`BinaryFiles`]([BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) which are added to the scene. 3. `files`: The [`BinaryFiles`]([BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) which are added to the scene.
@ -427,10 +433,10 @@ This helps to load Excalidraw with `initialData`. It must be an object or a [pro
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) | The elements with which Excalidraw should be mounted. | | `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106) | The elements with which Excalidraw should be mounted. |
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) | The App state with which Excalidraw should be mounted. | | `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) | The App state with which Excalidraw should be mounted. |
| `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained | | `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained |
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L151) | This library items with which Excalidraw should be mounted. | | `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) &#124; Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> | This library items with which Excalidraw should be mounted. |
| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) | The files added to the scene. | | `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64) | The files added to the scene. |
```json ```json
@ -472,19 +478,23 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| --- | --- | --- | | --- | --- | --- |
| ready | `boolean` | This is set to true once Excalidraw is rendered | | ready | `boolean` | This is set to true once Excalidraw is rendered |
| readyPromise | [resolvablePromise](https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317) | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readyPromise) | | readyPromise | [resolvablePromise](https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317) | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readyPromise) |
| [updateScene](#updateScene) | <pre>(scene: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>) => void </pre> | updates the scene with the sceneData | | [updateScene](#updateScene) | <code>(scene: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>) => void </code> | updates the scene with the sceneData |
| [addFiles](#addFiles) | <pre>(files: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts">BinaryFileData</a>) => void </pre> | add files data to the appState | | [updateLibrary](#updateLibrary) | <code>(<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/library.ts#L136">opts</a>) => Promise<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>> </code> | updates the scene with the sceneData |
| [addFiles](#addFiles) | <code>(files: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts">BinaryFileData</a>) => void </code> | add files data to the appState |
| resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| getSceneElementsIncludingDeleted | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a></pre> | Returns all the elements including the deleted in the scene | | getSceneElementsIncludingDeleted | <code> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a></code> | Returns all the elements including the deleted in the scene |
| getSceneElements | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a></pre> | Returns all the elements excluding the deleted in the scene | | getSceneElements | <code> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a></code> | Returns all the elements excluding the deleted in the scene |
| getAppState | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a></pre> | Returns current appState | | getAppState | <code> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L66">AppState</a></code> | Returns current appState |
| history | `{ clear: () => void }` | This is the history API. `history.clear()` will clear the history | | history | `{ clear: () => void }` | This is the history API. `history.clear()` will clear the history |
| scrollToContent | <pre> (target?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a> &#124; <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>[]) => void </pre> | Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene. | | scrollToContent | <code> (target?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement</a> &#124; <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement</a>[]) => void </code> | Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene. |
| refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. | | refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. |
| [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL | | [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL |
| setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. | | setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. |
| [id](#id) | string | Unique ID for the excalidraw component. | | [id](#id) | string | Unique ID for the excalidraw component. |
| [getFiles](#getFiles) | <pre>() => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64">files</a> </pre> | This API can be used to get the files present in the scene. It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements. | | [getFiles](#getFiles) | <code>() => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64">files</a> </code> | This API can be used to get the files present in the scene. It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements. |
| [setActiveTool](#setActiveTool) | <code>(tool: { type: typeof <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L4">SHAPES</a>[number]["value"] &#124; "eraser" } &#124; { type: "custom"; customType: string }) => void</code> | This API can be used to set the active tool |
| [setCursor](#setCursor) | <code>(cursor: string) => void </code> | This API can be used to set customise the mouse cursor on the canvas |
| [resetCursor](#resetCursor) | <code>() => void </code> | This API can be used to reset to default mouse cursor on the canvas |
#### `readyPromise` #### `readyPromise`
@ -506,9 +516,31 @@ You can use this function to update the scene with the sceneData. It accepts the
| --- | --- | --- | | --- | --- | --- |
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17) | The `elements` to be updated in the scene | | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L18) | The `appState` to be updated in the scene. | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L18) | The `appState` to be updated in the scene. |
| `collaborators` | <pre>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L29">Collaborator></a></pre> | The list of collaborators to be updated in the scene. | | `collaborators` | <pre>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L35">Collaborator></a></pre> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | | `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L258) | The `libraryItems` to be update in the scene. | | `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) &#124; Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> &#124; ((currentItems: [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) => [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) &#124; Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) | The `libraryItems` to be update in the scene. |
### `updateLibrary`
<pre>
(opts: {
libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L224">LibraryItemsSource</a>;
merge?: boolean;
prompt?: boolean;
openLibraryMenu?: boolean;
defaultStatus?: "unpublished" | "published";
}) => Promise<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>>
</pre>
You can use this function to update the library. It accepts the below attributes.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `libraryItems` | | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L224) | The `libraryItems` to be replaced/merged with current library |
| `merge` | boolean | `false` | Whether to merge with existing library items. |
| `prompt` | boolean | `false` | Whether to prompt user for confirmation. |
| `openLibraryMenu` | boolean | `false` | Whether to open the library menu before importing. |
| `defaultStatus` | <code>"unpublished" &#124; "published"</code> | `"unpublished"` | Default library item's `status` if not present. |
### `addFiles` ### `addFiles`
@ -543,7 +575,7 @@ This callback is triggered when mouse pointer is updated.
``` ```
1. `exportedElements`: An array of [non deleted elements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L87) which needs to be exported. 1. `exportedElements`: An array of [non deleted elements](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L87) which needs to be exported.
2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) of the scene. 2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) of the scene.
3. `canvas`: The `HTMLCanvasElement` of the scene. 3. `canvas`: The `HTMLCanvasElement` of the scene.
#### `langCode` #### `langCode`
@ -562,7 +594,7 @@ import { defaultLang, languages } from "@excalidraw/excalidraw";
#### `renderTopRightUI` #### `renderTopRightUI`
<pre> <pre>
(isMobile: boolean, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>) => JSX (isMobile: boolean, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>) => JSX | null
</pre> </pre>
A function returning JSX to render custom UI in the top right corner of the app. A function returning JSX to render custom UI in the top right corner of the app.
@ -570,7 +602,7 @@ A function returning JSX to render custom UI in the top right corner of the app.
#### `renderFooter` #### `renderFooter`
<pre> <pre>
(isMobile: boolean, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>) => JSX (isMobile: boolean, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>) => JSX | null
</pre> </pre>
A function returning JSX to render custom UI footer. For example, you can use this to render a language picker that was previously being rendered by Excalidraw itself (for now, you'll need to implement your own language picker). A function returning JSX to render custom UI footer. For example, you can use this to render a language picker that was previously being rendered by Excalidraw itself (for now, you'll need to implement your own language picker).
@ -605,7 +637,7 @@ This prop sets the name of the drawing which will be used when exporting the dra
#### `UIOptions` #### `UIOptions`
This prop can be used to customise UI of Excalidraw. Currently we support customising only [`canvasActions`](#canvasActions). It accepts the below parameters This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasActions) and [`dockedSidebarBreakpoint`](dockedSidebarBreakpoint). It accepts the below parameters
<pre> <pre>
{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> } { canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }
@ -623,6 +655,12 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
| `theme` | boolean | true | Implies whether to show `Theme toggle` | | `theme` | boolean | true | Implies whether to show `Theme toggle` |
| `saveAsImage` | boolean | true | Implies whether to show `Save as image button` | | `saveAsImage` | boolean | true | Implies whether to show `Save as image button` |
##### `dockedSidebarBreakpoint`
This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L167). If the `width` of the `excalidraw` container exceeds `dockedSidebarBreakpoint`, the sidebar will be dockable. If user choses to dock the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below.
![image](https://user-images.githubusercontent.com/11256141/174664866-c698c3fa-197b-43ff-956c-d79852c7b326.png)
#### `exportOpts` #### `exportOpts`
The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered. The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.
@ -667,6 +705,26 @@ useEffect(() => {
Try out the [Demo](#Demo) to see it in action. Try out the [Demo](#Demo) to see it in action.
#### `setActiveTool`
This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre>
(tool: { type: typeof <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L4">SHAPES</a>[number]["value"] &#124; "eraser" } &#124; { type: "custom"; customType: string }) => void
</pre>
#### `setCursor`
This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param.
<pre>
(cursor: string) => void
</pre>
#### `resetCursor`
This API can be used to reset to default mouse cursor.
#### `detectScroll` #### `detectScroll`
Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method). Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method).
@ -736,6 +794,22 @@ const onLinkOpen: ExcalidrawProps["onLinkOpen"] = useCallback(
); );
``` ```
#### `onPointerDown`
This prop if passed will be triggered on pointer down events and has the below signature.
<pre>
(activeTool: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L93"> AppState["activeTool"]</a>, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L365">PointerDownState</a>) => void
</pre>
#### `onScrollChange`
This prop if passed will be triggered when canvas is scrolled and has the below signature.
```ts
(scrollX: number, scrollY: number) => void
```
### Does it support collaboration ? ### Does it support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
@ -747,7 +821,7 @@ No, Excalidraw package doesn't come with collaboration built in, since the imple
**_Signature_** **_Signature_**
<pre> <pre>
restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17">ImportedDataState["appState"]</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>> | null): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a> restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L17">ImportedDataState["appState"]</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>> | null): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>
</pre> </pre>
**_How to use_** **_How to use_**
@ -756,7 +830,7 @@ restoreAppState(appState: <a href="https://github.com/excalidraw/excalidraw/blob
import { restoreAppState } from "@excalidraw/excalidraw"; import { restoreAppState } from "@excalidraw/excalidraw";
``` ```
This function will make sure all the keys have appropriate values in [appState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) and if any key is missing, it will be set to default value. This function will make sure all the keys have appropriate values in [appState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) and if any key is missing, it will be set to default value.
When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of defaults. Use this as a way to not override user's defaults if you persist them. Required: supply `null`/`undefined` if not applicable. When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of defaults. Use this as a way to not override user's defaults if you persist them. Required: supply `null`/`undefined` if not applicable.
@ -765,7 +839,7 @@ When `localAppState` is supplied, it's used in place of values that are missing
**_Signature_** **_Signature_**
<pre> <pre>
restoreElements(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ImportedDataState["elements"]</a>, localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a> restoreElements(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ImportedDataState["elements"]</a>, localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>
</pre> </pre>
**_How to use_** **_How to use_**
@ -783,7 +857,7 @@ When `localElements` are supplied, they are used to ensure that existing restore
**_Signature_** **_Signature_**
<pre> <pre>
restoreElements(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L12">ImportedDataState</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>> | null | undefined, localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a> restoreElements(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L12">ImportedDataState</a>, localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>> | null | undefined, localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L16">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a>
</pre> </pre>
See [`restoreAppState()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreAppState) about `localAppState`, and [`restoreElements()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreElements) about `localElements`. See [`restoreAppState()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreAppState) about `localAppState`, and [`restoreElements()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#restoreElements) about `localElements`.
@ -796,6 +870,24 @@ import { restore } from "@excalidraw/excalidraw";
This function makes sure elements and state is set to appropriate values and set to default value if not present. It is a combination of [restoreElements](#restoreElements) and [restoreAppState](#restoreAppState). This function makes sure elements and state is set to appropriate values and set to default value if not present. It is a combination of [restoreElements](#restoreElements) and [restoreAppState](#restoreAppState).
#### `restoreLibraryItems`
**_Signature_**
<pre>
restoreLibraryItems(libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L22">ImportedDataState["libraryItems"]</a>, defaultStatus: "published" | "unpublished")
</pre>
**_How to use_**
```js
import { restoreLibraryItems } from "@excalidraw/excalidraw";
restoreLibraryItems(libraryItems, "unpublished");
```
This function normalizes library items elements, adding missing values when needed.
### Export utilities ### Export utilities
#### `exportToCanvas` #### `exportToCanvas`
@ -833,7 +925,7 @@ This function returns the canvas with the exported elements, appState and dimens
<pre> <pre>
exportToBlob( exportToBlob(
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L10">ExportOpts</a> & { opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L14">ExportOpts</a> & {
mimeType?: string, mimeType?: string,
quality?: number; quality?: number;
}) })
@ -859,8 +951,8 @@ Returns a promise which resolves with a [blob](https://developer.mozilla.org/en-
<pre> <pre>
exportToSvg({ exportToSvg({
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>, elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>,
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>,
exportPadding?: number, exportPadding?: number,
metadata?: string, metadata?: string,
files?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64">BinaryFiles</a> files?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64">BinaryFiles</a>
@ -869,13 +961,41 @@ exportToSvg({
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) | | The elements to exported as svg | | elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106) | | The elements to exported as svg |
| appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L11) | The app state of the scene | | appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L11) | The app state of the scene |
| exportPadding | number | 10 | The padding to be added on canvas | | exportPadding | number | 10 | The padding to be added on canvas |
| files | [BinaryFiles](The [`BinaryFiles`](<[BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64)>) | undefined | The files added to the scene. | | files | [BinaryFiles](The [`BinaryFiles`](<[BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L64)>) | undefined | The files added to the scene. |
This function returns a promise which resolves to svg of the exported drawing. This function returns a promise which resolves to svg of the exported drawing.
#### `exportToClipboard`
**_Signature_**
<pre>
exportToClipboard(
opts: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L14">ExportOpts</a> & {
mimeType?: string,
quality?: number;
type: 'png' | 'svg' |'json'
})
</pre>
| Name | Type | Default | Description |
| --- | --- | --- | --- | --- | --- |
| opts | | | This param is same as the params passed to `exportToCanvas`. You can refer to [`exportToCanvas`](#exportToCanvas). |
| mimeType | string | "image/png" | Indicates the image format, this will be used when exporting as `png`. |
| quality | number | 0.92 | A value between 0 and 1 indicating the [image quality](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#parameters). Applies only to `image/jpeg`/`image/webp` MIME types. This will be used when exporting as `png`. |
| type | 'png' | 'svg' | 'json' | | This determines the format to which the scene data should be exported. |
**How to use**
```js
import { exportToClipboard } from "@excalidraw/excalidraw";
```
Copies the scene data in the specified format (determined by `type`) to clipboard.
##### Additional attributes of appState for `export\*` APIs ##### Additional attributes of appState for `export\*` APIs
| Name | Type | Default | Description | | Name | Type | Default | Description |
@ -883,7 +1003,7 @@ This function returns a promise which resolves to svg of the exported drawing.
| exportBackground | boolean | true | Indicates whether background should be exported | | exportBackground | boolean | true | Indicates whether background should be exported |
| viewBackgroundColor | string | #fff | The default background color | | viewBackgroundColor | string | #fff | The default background color |
| exportWithDarkMode | boolean | false | Indicates whether to export with dark mode | | exportWithDarkMode | boolean | false | Indicates whether to export with dark mode |
| exportEmbedScene | boolean | false | Indicates whether scene data should be embedded in svg. This will increase the svg size. | | exportEmbedScene | boolean | false | Indicates whether scene data should be embedded in svg/png. This will increase the image size. |
### Extra API's ### Extra API's
@ -893,20 +1013,35 @@ This function returns a promise which resolves to svg of the exported drawing.
<pre> <pre>
serializeAsJSON({ serializeAsJSON({
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>, elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>,
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a>, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>,
}): string }): string
</pre> </pre>
Takes the scene elements and state and returns a JSON string. Deleted `elements`as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L16) source for details). Takes the scene elements and state and returns a JSON string. Deleted `elements`as well as most properties from `AppState` are removed from the resulting JSON. (see [`serializeAsJSON()`](https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L16) source for details).
If you want to overwrite the source field in the JSON string, you can set `window.EXCALIDRAW_EXPORT_SOURCE` to the desired value.
#### `serializeLibraryAsJSON`
**_Signature_**
<pre>
serializeLibraryAsJSON({
libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems[]</a>,
</pre>
Takes the library items and returns a JSON string.
If you want to overwrite the source field in the JSON string, you can set `window.EXCALIDRAW_EXPORT_SOURCE` to the desired value.
#### `getSceneVersion` #### `getSceneVersion`
**How to use** **How to use**
<pre> <pre>
import { getSceneVersion } from "@excalidraw/excalidraw"; import { getSceneVersion } from "@excalidraw/excalidraw";
getSceneVersion(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>) getSceneVersion(elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>)
</pre> </pre>
This function returns the current scene version. This function returns the current scene version.
@ -916,7 +1051,7 @@ This function returns the current scene version.
**_Signature_** **_Signature_**
<pre> <pre>
isInvisiblySmallElement(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>): boolean isInvisiblySmallElement(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement</a>): boolean
</pre> </pre>
**How to use** **How to use**
@ -947,15 +1082,51 @@ This function loads the library from the blob.
```js ```js
import { loadFromBlob } from "@excalidraw/excalidraw"; import { loadFromBlob } from "@excalidraw/excalidraw";
const scene = await loadFromBlob(file, null, null);
excalidrawAPI.updateScene(scene);
``` ```
**Signature** **Signature**
<pre> <pre>
loadFromBlob(blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>, localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L42">AppState</a> | null) loadFromBlob(
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a> | null,
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a> | null,
fileHandle?: FileSystemHandle | null
) => Promise<<a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/restore.ts#L53">RestoredDataState</a>>
</pre> </pre>
This function loads the scene data from the blob. If you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob` This function loads the scene data from the blob (or file). If you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob`. Throws if blob doesn't contain valid scene data.
#### `loadSceneOrLibraryFromBlob`
**How to use**
```js
import { loadSceneOrLibraryFromBlob, MIME_TYPES } from "@excalidraw/excalidraw";
const contents = await loadSceneOrLibraryFromBlob(file, null, null);
if (contents.type === MIME_TYPES.excalidraw) {
excalidrawAPI.updateScene(contents.data);
} else if (contents.type === MIME_TYPES.excalidrawlib) {
excalidrawAPI.updateLibrary(contents.data);
}
```
**Signature**
<pre>
loadSceneOrLibraryFromBlob(
blob: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob">Blob</a>,
localAppState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a> | null,
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a> | null,
fileHandle?: FileSystemHandle | null
) => Promise<{ type: string, data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/restore.ts#L53">RestoredDataState</a> | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L33">ImportedLibraryState</a>}>
</pre>
This function loads either scene or library data from the supplied blob. If the blob contains scene data, and you pass `localAppState`, `localAppState` value will be preferred over the `appState` derived from `blob`. Throws if blob doesn't contain neither valid scene data or library data.
#### `getFreeDrawSvgPath` #### `getFreeDrawSvgPath`
@ -1005,6 +1176,93 @@ getNonDeletedElements(elements: <a href="https://github.com/excalidraw/excalidra
This function returns an array of deleted elements. This function returns an array of deleted elements.
#### `mergeLibraryItems`
```js
import { mergeLibraryItems } from "@excalidraw/excalidraw";
```
**_Signature_**
<pre>
mergeLibraryItems(localItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>, otherItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>
</pre>
This function merges two `LibraryItems` arrays, where unique items from `otherItems` are sorted first in the returned array.
#### `parseLibraryTokensFromUrl`
**How to use**
```js
import { parseLibraryTokensFromUrl } from "@excalidraw/excalidraw";
```
**Signature**
<pre>
parseLibraryTokensFromUrl(): {
libraryUrl: string;
idToken: string | null;
} | null
</pre>
Parses library parameters from URL if present (expects the `#addLibrary` hash key), and returns an object with the `libraryUrl` and `idToken`. Returns `null` if `#addLibrary` hash key not found.
#### `useHandleLibrary`
**How to use**
```js
import { useHandleLibrary } from "@excalidraw/excalidraw";
export const App = () => {
// ...
useHandleLibrary({ excalidrawAPI });
};
```
**Signature**
<pre>
useHandleLibrary(opts: {
excalidrawAPI: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L432">ExcalidrawAPI</a>,
getInitialLibraryItems?: () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L224">LibraryItemsSource</a>
});
</pre>
A hook that automatically imports library from url if `#addLibrary` hash key exists on initial load, or when it changes during the editing session (e.g. when a user installs a new library), and handles initial library load if `getInitialLibraryItems` getter is supplied.
In the future, we will be adding support for handling library persistence to browser storage (or elsewhere).
#### `sceneCoordsToViewportCoords`
```js
import { sceneCoordsToViewportCoords } from "@excalidraw/excalidraw";
```
**_Signature_**
<pre>
sceneCoordsToViewportCoords({sceneX: number, sceneY: number}, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>): {x: number, y: number}
</pre>
This function returns equivalent viewport coords for the provided scene coords in params.
#### `viewportCoordsToSceneCoords`
```js
import { viewportCoordsToSceneCoords } from "@excalidraw/excalidraw";
```
**_Signature_**
<pre>
viewportCoordsToSceneCoords({clientX: number, clientY: number}, appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState</a>): {x: number, y: number}
</pre>
This function returns equivalent scene coords for the provided viewport coords in params.
### Exported constants ### Exported constants
#### `FONT_FAMILY` #### `FONT_FAMILY`
@ -1042,6 +1300,16 @@ import { THEME } from "@excalidraw/excalidraw";
Defaults to `THEME.LIGHT` unless passed in `initialData.appState.theme` Defaults to `THEME.LIGHT` unless passed in `initialData.appState.theme`
### `MIME_TYPES`
**How to use **
```js
import { MIME_TYPES } from "@excalidraw/excalidraw";
```
[`MIME_TYPES`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L92) contains all the mime types supported by `Excalidraw`.
## Need help? ## Need help?
Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw).
@ -1069,7 +1337,7 @@ The example is same as the [codesandbox example](https://ehlz3.csb.app/)
You can create a test release by posting the below comment in your pull request You can create a test release by posting the below comment in your pull request
``` ```
@excalibot release package @excalibot trigger release
``` ```
Once the version is released `@excalibot` will post a comment with the release version. Once the version is released `@excalibot` will post a comment with the release version.

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,13 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import InitialData from "./initialData";
import Sidebar from "./sidebar/Sidebar"; import Sidebar from "./sidebar/Sidebar";
import "./App.scss"; import "./App.scss";
import initialData from "./initialData"; import initialData from "./initialData";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { import {
resolvablePromise,
ResolvablePromise,
withBatchedUpdates, withBatchedUpdates,
withBatchedUpdatesThrottled, withBatchedUpdatesThrottled,
} from "../../../utils"; } from "../../../utils";
@ -14,7 +15,42 @@ import { EVENT } from "../../../constants";
import { distance2d } from "../../../math"; import { distance2d } from "../../../math";
import { fileOpen } from "../../../data/filesystem"; import { fileOpen } from "../../../data/filesystem";
import { loadSceneOrLibraryFromBlob } from "../../utils"; import { loadSceneOrLibraryFromBlob } from "../../utils";
import {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "../../../types";
import { ExcalidrawElement } from "../../../element/types";
import { ImportedLibraryData } from "../../../data/types";
declare global {
interface Window {
ExcalidrawLib: any;
}
}
type Comment = {
x: number;
y: number;
value: string;
id?: string;
};
type PointerDownState = {
x: number;
y: number;
hitElement: Comment;
onMove: any;
onUp: any;
hitElementOffsets: {
x: number;
y: number;
};
};
// This is so that we use the bundled excalidraw.development.js file instead // This is so that we use the bundled excalidraw.development.js file instead
// of the actual source code // of the actual source code
@ -28,6 +64,7 @@ const {
MIME_TYPES, MIME_TYPES,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
restoreElements,
} = window.ExcalidrawLib; } = window.ExcalidrawLib;
const COMMENT_SVG = ( const COMMENT_SVG = (
@ -41,7 +78,7 @@ const COMMENT_SVG = (
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="feather feather-message-circle" className="feather feather-message-circle"
> >
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg> </svg>
@ -50,18 +87,6 @@ const COMMENT_ICON_DIMENSION = 32;
const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_HEIGHT = 50;
const COMMENT_INPUT_WIDTH = 150; const COMMENT_INPUT_WIDTH = 150;
const resolvablePromise = () => {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
promise.resolve = resolve;
promise.reject = reject;
return promise;
};
const renderTopRightUI = () => { const renderTopRightUI = () => {
return ( return (
<button <button
@ -75,25 +100,31 @@ const renderTopRightUI = () => {
}; };
export default function App() { export default function App() {
const appRef = useRef(null); const appRef = useRef<any>(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false); const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false); const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [blobUrl, setBlobUrl] = useState(null); const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState(null); const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
const [exportEmbedScene, setExportEmbedScene] = useState(false); const [exportEmbedScene, setExportEmbedScene] = useState(false);
const [theme, setTheme] = useState("light"); const [theme, setTheme] = useState("light");
const [isCollaborating, setIsCollaborating] = useState(false); const [isCollaborating, setIsCollaborating] = useState(false);
const [commentIcons, setCommentIcons] = useState({}); const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>(
const [comment, setComment] = useState(null); {},
);
const [comment, setComment] = useState<Comment | null>(null);
const initialStatePromiseRef = useRef({ promise: null }); const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) { if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise(); initialStatePromiseRef.current.promise =
resolvablePromise<ExcalidrawInitialDataState | null>();
} }
const [excalidrawAPI, setExcalidrawAPI] = useState(null); const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI | null>(null);
useHandleLibrary({ excalidrawAPI }); useHandleLibrary({ excalidrawAPI });
@ -108,16 +139,17 @@ export default function App() {
reader.readAsDataURL(imageData); reader.readAsDataURL(imageData);
reader.onload = function () { reader.onload = function () {
const imagesArray = [ const imagesArray: BinaryFileData[] = [
{ {
id: "rocket", id: "rocket" as BinaryFileData["id"],
dataURL: reader.result, dataURL: reader.result as BinaryFileData["dataURL"],
mimeType: MIME_TYPES.jpg, mimeType: MIME_TYPES.jpg,
created: 1644915140367, created: 1644915140367,
}, },
]; ];
initialStatePromiseRef.current.promise.resolve(InitialData); //@ts-ignore
initialStatePromiseRef.current.promise.resolve(initialData);
excalidrawAPI.addFiles(imagesArray); excalidrawAPI.addFiles(imagesArray);
}; };
}; };
@ -131,7 +163,7 @@ export default function App() {
<button <button
className="custom-element" className="custom-element"
onClick={() => { onClick={() => {
excalidrawAPI.setActiveTool({ excalidrawAPI?.setActiveTool({
type: "custom", type: "custom",
customType: "comment", customType: "comment",
}); });
@ -151,7 +183,7 @@ export default function App() {
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg>`, </svg>`,
)}`; )}`;
excalidrawAPI.setCursor(`url(${url}), auto`); excalidrawAPI?.setCursor(`url(${url}), auto`);
}} }}
> >
{COMMENT_SVG} {COMMENT_SVG}
@ -168,10 +200,10 @@ export default function App() {
const file = await fileOpen({ description: "Excalidraw or library file" }); const file = await fileOpen({ description: "Excalidraw or library file" });
const contents = await loadSceneOrLibraryFromBlob(file, null, null); const contents = await loadSceneOrLibraryFromBlob(file, null, null);
if (contents.type === MIME_TYPES.excalidraw) { if (contents.type === MIME_TYPES.excalidraw) {
excalidrawAPI.updateScene(contents.data); excalidrawAPI?.updateScene(contents.data as any);
} else if (contents.type === MIME_TYPES.excalidrawlib) { } else if (contents.type === MIME_TYPES.excalidrawlib) {
excalidrawAPI.updateLibrary({ excalidrawAPI?.updateLibrary({
libraryItems: contents.data.libraryItems, libraryItems: (contents.data as ImportedLibraryData).libraryItems!,
openLibraryMenu: true, openLibraryMenu: true,
}); });
} }
@ -179,34 +211,42 @@ export default function App() {
const updateScene = () => { const updateScene = () => {
const sceneData = { const sceneData = {
elements: [ elements: restoreElements(
{ [
type: "rectangle", {
version: 141, type: "rectangle",
versionNonce: 361174001, version: 141,
isDeleted: false, versionNonce: 361174001,
id: "oDVXy8D6rom3H1-LLH2-f", isDeleted: false,
fillStyle: "hachure", id: "oDVXy8D6rom3H1-LLH2-f",
strokeWidth: 1, fillStyle: "hachure",
strokeStyle: "solid", strokeWidth: 1,
roughness: 1, strokeStyle: "solid",
opacity: 100, roughness: 1,
angle: 0, opacity: 100,
x: 100.50390625, angle: 0,
y: 93.67578125, x: 100.50390625,
strokeColor: "#c92a2a", y: 93.67578125,
backgroundColor: "transparent", strokeColor: "#c92a2a",
width: 186.47265625, backgroundColor: "transparent",
height: 141.9765625, width: 186.47265625,
seed: 1968410350, height: 141.9765625,
groupIds: [], seed: 1968410350,
}, groupIds: [],
], boundElements: null,
locked: false,
link: null,
updated: 1,
strokeSharpness: "round",
},
],
null,
),
appState: { appState: {
viewBackgroundColor: "#edf2ff", viewBackgroundColor: "#edf2ff",
}, },
}; };
excalidrawAPI.updateScene(sceneData); excalidrawAPI?.updateScene(sceneData);
}; };
const onLinkOpen = useCallback((element, event) => { const onLinkOpen = useCallback((element, event) => {
@ -224,19 +264,26 @@ export default function App() {
} }
}, []); }, []);
const onCopy = async (type) => { const onCopy = async (type: string) => {
await exportToClipboard({ await exportToClipboard({
elements: excalidrawAPI.getSceneElements(), elements: excalidrawAPI?.getSceneElements(),
appState: excalidrawAPI.getAppState(), appState: excalidrawAPI?.getAppState(),
files: excalidrawAPI.getFiles(), files: excalidrawAPI?.getFiles(),
type, type,
}); });
window.alert(`Copied to clipboard as ${type} sucessfully`); window.alert(`Copied to clipboard as ${type} sucessfully`);
}; };
const [pointerData, setPointerData] = useState(null); const [pointerData, setPointerData] = useState<{
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
} | null>(null);
const onPointerDown = (activeTool, pointerDownState) => { const onPointerDown = (
activeTool: AppState["activeTool"],
pointerDownState: ExcalidrawPointerDownState,
) => {
if (activeTool.type === "custom" && activeTool.customType === "comment") { if (activeTool.type === "custom" && activeTool.customType === "comment") {
const { x, y } = pointerDownState.origin; const { x, y } = pointerDownState.origin;
setComment({ x, y, value: "" }); setComment({ x, y, value: "" });
@ -244,48 +291,53 @@ export default function App() {
}; };
const rerenderCommentIcons = () => { const rerenderCommentIcons = () => {
const commentIconsElements = const commentIconsElements = appRef.current.querySelectorAll(
appRef.current.querySelectorAll(".comment-icon"); ".comment-icon",
) as HTMLElement[];
commentIconsElements.forEach((ele) => { commentIconsElements.forEach((ele) => {
const id = ele.id; const id = ele.id;
const appstate = excalidrawAPI.getAppState(); const appstate = excalidrawAPI?.getAppState();
const { x, y } = sceneCoordsToViewportCoords( const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: commentIcons[id].x, sceneY: commentIcons[id].y }, { sceneX: commentIcons[id].x, sceneY: commentIcons[id].y },
appstate, appstate,
); );
ele.style.left = `${ ele.style.left = `${
x - COMMENT_ICON_DIMENSION / 2 - appstate.offsetLeft x - COMMENT_ICON_DIMENSION / 2 - appstate!.offsetLeft
}px`; }px`;
ele.style.top = `${ ele.style.top = `${
y - COMMENT_ICON_DIMENSION / 2 - appstate.offsetTop y - COMMENT_ICON_DIMENSION / 2 - appstate!.offsetTop
}px`; }px`;
}); });
}; };
const onPointerMoveFromPointerDownHandler = (pointerDownState) => { const onPointerMoveFromPointerDownHandler = (
pointerDownState: PointerDownState,
) => {
return withBatchedUpdatesThrottled((event) => { return withBatchedUpdatesThrottled((event) => {
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
{ {
clientX: event.clientX - pointerDownState.hitElementOffsets.x, clientX: event.clientX - pointerDownState.hitElementOffsets.x,
clientY: event.clientY - pointerDownState.hitElementOffsets.y, clientY: event.clientY - pointerDownState.hitElementOffsets.y,
}, },
excalidrawAPI.getAppState(), excalidrawAPI?.getAppState(),
); );
setCommentIcons({ setCommentIcons({
...commentIcons, ...commentIcons,
[pointerDownState.hitElement.id]: { [pointerDownState.hitElement.id!]: {
...commentIcons[pointerDownState.hitElement.id], ...commentIcons[pointerDownState.hitElement.id!],
x, x,
y, y,
}, },
}); });
}); });
}; };
const onPointerUpFromPointerDownHandler = (pointerDownState) => { const onPointerUpFromPointerDownHandler = (
pointerDownState: PointerDownState,
) => {
return withBatchedUpdates((event) => { return withBatchedUpdates((event) => {
window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove); window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove);
window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp); window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp);
excalidrawAPI.setActiveTool({ type: "selection" }); excalidrawAPI?.setActiveTool({ type: "selection" });
const distance = distance2d( const distance = distance2d(
pointerDownState.x, pointerDownState.x,
pointerDownState.y, pointerDownState.y,
@ -308,18 +360,18 @@ export default function App() {
}; };
const renderCommentIcons = () => { const renderCommentIcons = () => {
return Object.values(commentIcons).map((commentIcon) => { return Object.values(commentIcons).map((commentIcon) => {
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI?.getAppState();
const { x, y } = sceneCoordsToViewportCoords( const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: commentIcon.x, sceneY: commentIcon.y }, { sceneX: commentIcon.x, sceneY: commentIcon.y },
excalidrawAPI.getAppState(), excalidrawAPI?.getAppState(),
); );
return ( return (
<div <div
id={commentIcon.id} id={commentIcon.id}
key={commentIcon.id} key={commentIcon.id}
style={{ style={{
top: `${y - COMMENT_ICON_DIMENSION / 2 - appState.offsetTop}px`, top: `${y - COMMENT_ICON_DIMENSION / 2 - appState!.offsetTop}px`,
left: `${x - COMMENT_ICON_DIMENSION / 2 - appState.offsetLeft}px`, left: `${x - COMMENT_ICON_DIMENSION / 2 - appState!.offsetLeft}px`,
position: "absolute", position: "absolute",
zIndex: 1, zIndex: 1,
width: `${COMMENT_ICON_DIMENSION}px`, width: `${COMMENT_ICON_DIMENSION}px`,
@ -334,7 +386,7 @@ export default function App() {
commentIcon.value = comment.value; commentIcon.value = comment.value;
saveComment(); saveComment();
} }
const pointerDownState = { const pointerDownState: any = {
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
hitElement: commentIcon, hitElement: commentIcon,
@ -350,7 +402,7 @@ export default function App() {
pointerDownState.onMove = onPointerMove; pointerDownState.onMove = onPointerMove;
pointerDownState.onUp = onPointerUp; pointerDownState.onUp = onPointerUp;
excalidrawAPI.setActiveTool({ excalidrawAPI?.setActiveTool({
type: "custom", type: "custom",
customType: "comment", customType: "comment",
}); });
@ -365,6 +417,9 @@ export default function App() {
}; };
const saveComment = () => { const saveComment = () => {
if (!comment) {
return;
}
if (!comment.id && !comment.value) { if (!comment.id && !comment.value) {
setComment(null); setComment(null);
return; return;
@ -383,7 +438,10 @@ export default function App() {
}; };
const renderComment = () => { const renderComment = () => {
const appState = excalidrawAPI.getAppState(); if (!comment) {
return null;
}
const appState = excalidrawAPI?.getAppState()!;
const { x, y } = sceneCoordsToViewportCoords( const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: comment.x, sceneY: comment.y }, { sceneX: comment.x, sceneY: comment.y },
appState, appState,
@ -450,24 +508,29 @@ export default function App() {
<button <button
className="reset-scene" className="reset-scene"
onClick={() => { onClick={() => {
excalidrawAPI.resetScene(); excalidrawAPI?.resetScene();
}} }}
> >
Reset Scene Reset Scene
</button> </button>
<button <button
onClick={() => { onClick={() => {
excalidrawAPI.updateLibrary({ const libraryItems: LibraryItems = [
libraryItems: [ {
{ status: "published",
status: "published", id: "1",
elements: initialData.libraryItems[0], created: 1,
}, elements: initialData.libraryItems[1] as any,
{ },
status: "unpublished", {
elements: initialData.libraryItems[1], status: "unpublished",
}, id: "2",
], created: 2,
elements: initialData.libraryItems[1] as any,
},
];
excalidrawAPI?.updateLibrary({
libraryItems,
}); });
}} }}
> >
@ -535,9 +598,9 @@ export default function App() {
username: "fallback", username: "fallback",
avatarUrl: "https://example.com", avatarUrl: "https://example.com",
}); });
excalidrawAPI.updateScene({ collaborators }); excalidrawAPI?.updateScene({ collaborators });
} else { } else {
excalidrawAPI.updateScene({ excalidrawAPI?.updateScene({
collaborators: new Map(), collaborators: new Map(),
}); });
} }
@ -571,12 +634,16 @@ export default function App() {
</div> </div>
<div className="excalidraw-wrapper"> <div className="excalidraw-wrapper">
<Excalidraw <Excalidraw
ref={(api) => setExcalidrawAPI(api)} ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => { onChange={(elements: ExcalidrawElement[], state: AppState) => {
console.info("Elements :", elements, "State : ", state); console.info("Elements :", elements, "State : ", state);
}} }}
onPointerUpdate={(payload) => setPointerData(payload)} onPointerUpdate={(payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => setPointerData(payload)}
onCollabButtonClick={() => onCollabButtonClick={() =>
window.alert("You clicked on collab button") window.alert("You clicked on collab button")
} }
@ -616,7 +683,7 @@ export default function App() {
<button <button
onClick={async () => { onClick={async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: excalidrawAPI.getSceneElements(), elements: excalidrawAPI?.getSceneElements(),
appState: { appState: {
...initialData.appState, ...initialData.appState,
exportWithDarkMode, exportWithDarkMode,
@ -625,7 +692,7 @@ export default function App() {
height: 100, height: 100,
}, },
embedScene: true, embedScene: true,
files: excalidrawAPI.getFiles(), files: excalidrawAPI?.getFiles(),
}); });
appRef.current.querySelector(".export-svg").innerHTML = appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML; svg.outerHTML;
@ -638,14 +705,14 @@ export default function App() {
<button <button
onClick={async () => { onClick={async () => {
const blob = await exportToBlob({ const blob = await exportToBlob({
elements: excalidrawAPI.getSceneElements(), elements: excalidrawAPI?.getSceneElements(),
mimeType: "image/png", mimeType: "image/png",
appState: { appState: {
...initialData.appState, ...initialData.appState,
exportEmbedScene, exportEmbedScene,
exportWithDarkMode, exportWithDarkMode,
}, },
files: excalidrawAPI.getFiles(), files: excalidrawAPI?.getFiles(),
}); });
setBlobUrl(window.URL.createObjectURL(blob)); setBlobUrl(window.URL.createObjectURL(blob));
}} }}
@ -659,12 +726,12 @@ export default function App() {
<button <button
onClick={async () => { onClick={async () => {
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(), elements: excalidrawAPI?.getSceneElements(),
appState: { appState: {
...initialData.appState, ...initialData.appState,
exportWithDarkMode, exportWithDarkMode,
}, },
files: excalidrawAPI.getFiles(), files: excalidrawAPI?.getFiles(),
}); });
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
ctx.font = "30px Virgil"; ctx.font = "30px Virgil";

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import React, { useState } from "react";
import "./Sidebar.scss"; import "./Sidebar.scss";
export default function Sidebar(props) { export default function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@ -23,7 +23,7 @@ export default function Sidebar(props) {
> >
Open Sidebar Open Sidebar
</button> </button>
{props.children} {children}
</div> </div>
</> </>
); );

View file

@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
const UIOptions: AppProps["UIOptions"] = { const UIOptions: AppProps["UIOptions"] = {
...props.UIOptions,
canvasActions: { canvasActions: {
...DEFAULT_UI_OPTIONS.canvasActions, ...DEFAULT_UI_OPTIONS.canvasActions,
...canvasActions, ...canvasActions,

View file

@ -49,7 +49,7 @@
"@babel/plugin-transform-async-to-generator": "7.16.0", "@babel/plugin-transform-async-to-generator": "7.16.0",
"@babel/plugin-transform-runtime": "7.17.10", "@babel/plugin-transform-runtime": "7.17.10",
"@babel/plugin-transform-typescript": "7.16.1", "@babel/plugin-transform-typescript": "7.16.1",
"@babel/preset-env": "7.17.10", "@babel/preset-env": "7.18.6",
"@babel/preset-react": "7.16.7", "@babel/preset-react": "7.16.7",
"@babel/preset-typescript": "7.16.7", "@babel/preset-typescript": "7.16.7",
"autoprefixer": "10.4.7", "autoprefixer": "10.4.7",
@ -66,7 +66,7 @@
"webpack": "5.72.0", "webpack": "5.72.0",
"webpack-bundle-analyzer": "4.5.0", "webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.9.2", "webpack-cli": "4.9.2",
"webpack-dev-server": "4.9.0", "webpack-dev-server": "4.9.3",
"webpack-merge": "5.8.0" "webpack-merge": "5.8.0"
}, },
"bugs": "https://github.com/excalidraw/excalidraw/issues", "bugs": "https://github.com/excalidraw/excalidraw/issues",

View file

@ -5,7 +5,7 @@ const devConfig = require("./webpack.dev.config");
const devServerConfig = { const devServerConfig = {
entry: { entry: {
bundle: "./example/index.js", bundle: "./example/index.tsx",
}, },
// Server Configuration options // Server Configuration options
devServer: { devServer: {

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,7 @@
"@babel/core": "7.17.2", "@babel/core": "7.17.2",
"@babel/plugin-transform-arrow-functions": "7.16.0", "@babel/plugin-transform-arrow-functions": "7.16.0",
"@babel/plugin-transform-async-to-generator": "7.16.5", "@babel/plugin-transform-async-to-generator": "7.16.5",
"@babel/plugin-transform-runtime": "7.17.10", "@babel/plugin-transform-runtime": "7.18.6",
"@babel/plugin-transform-typescript": "7.16.1", "@babel/plugin-transform-typescript": "7.16.1",
"@babel/preset-env": "7.16.7", "@babel/preset-env": "7.16.7",
"@babel/preset-typescript": "7.16.7", "@babel/preset-typescript": "7.16.7",

View file

@ -205,12 +205,12 @@
dependencies: dependencies:
"@babel/types" "^7.16.7" "@babel/types" "^7.16.7"
"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.16.0", "@babel/helper-module-imports@^7.16.7": "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.16.0", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.18.6":
version "7.16.7" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz#25612a8091a999704461c8a222d0efec5d091437" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e"
integrity sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg== integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==
dependencies: dependencies:
"@babel/types" "^7.16.7" "@babel/types" "^7.18.6"
"@babel/helper-module-transforms@^7.16.7": "@babel/helper-module-transforms@^7.16.7":
version "7.16.7" version "7.16.7"
@ -233,10 +233,10 @@
dependencies: dependencies:
"@babel/types" "^7.16.7" "@babel/types" "^7.16.7"
"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
version "7.16.7" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.6.tgz#9448974dd4fb1d80fefe72e8a0af37809cd30d6d"
integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== integrity sha512-gvZnm1YAAxh13eJdkb9EWHBnF3eAub3XTLCZEehHT2kWxiKVRL64+ae5Y6Ivne0mVHmMYKT+xWgZO+gQhuLUBg==
"@babel/helper-remap-async-to-generator@^7.16.5": "@babel/helper-remap-async-to-generator@^7.16.5":
version "7.16.5" version "7.16.5"
@ -293,6 +293,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad"
integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==
"@babel/helper-validator-identifier@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
"@babel/helper-validator-option@^7.16.7": "@babel/helper-validator-option@^7.16.7":
version "7.16.7" version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23"
@ -805,16 +810,16 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.16.7" "@babel/helper-plugin-utils" "^7.16.7"
"@babel/plugin-transform-runtime@7.17.10": "@babel/plugin-transform-runtime@7.18.6":
version "7.17.10" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.10.tgz#b89d821c55d61b5e3d3c3d1d636d8d5a81040ae1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.6.tgz#77b14416015ea93367ca06979710f5000ff34ccb"
integrity sha512-6jrMilUAJhktTr56kACL8LnWC5hx3Lf27BS0R0DSyW/OoJfb/iTHeE96V3b1dgKG3FSFdd/0culnYWMkjcKCig== integrity sha512-8uRHk9ZmRSnWqUgyae249EJZ94b0yAGLBIqzZzl+0iEdbno55Pmlt/32JZsHwXD9k/uZj18Aqqk35wBX4CBTXA==
dependencies: dependencies:
"@babel/helper-module-imports" "^7.16.7" "@babel/helper-module-imports" "^7.18.6"
"@babel/helper-plugin-utils" "^7.16.7" "@babel/helper-plugin-utils" "^7.18.6"
babel-plugin-polyfill-corejs2 "^0.3.0" babel-plugin-polyfill-corejs2 "^0.3.1"
babel-plugin-polyfill-corejs3 "^0.5.0" babel-plugin-polyfill-corejs3 "^0.5.2"
babel-plugin-polyfill-regenerator "^0.3.0" babel-plugin-polyfill-regenerator "^0.3.1"
semver "^6.3.0" semver "^6.3.0"
"@babel/plugin-transform-shorthand-properties@^7.16.7": "@babel/plugin-transform-shorthand-properties@^7.16.7":
@ -1026,6 +1031,14 @@
"@babel/helper-validator-identifier" "^7.16.7" "@babel/helper-validator-identifier" "^7.16.7"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@babel/types@^7.18.6":
version "7.18.7"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.7.tgz#a4a2c910c15040ea52cdd1ddb1614a65c8041726"
integrity sha512-QG3yxTcTIBoAcQmkCs+wAPYZhu7Dk9rXKacINfNbdJDNERTbLQbHGyVG8q/YGMPeCJRIhSY0+fTc5+xuh6WPSQ==
dependencies:
"@babel/helper-validator-identifier" "^7.18.6"
to-fast-properties "^2.0.0"
"@discoveryjs/json-ext@^0.5.0": "@discoveryjs/json-ext@^0.5.0":
version "0.5.2" version "0.5.2"
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752"
@ -1344,13 +1357,13 @@ babel-plugin-dynamic-import-node@^2.3.3:
dependencies: dependencies:
object.assign "^4.1.0" object.assign "^4.1.0"
babel-plugin-polyfill-corejs2@^0.3.0: babel-plugin-polyfill-corejs2@^0.3.0, babel-plugin-polyfill-corejs2@^0.3.1:
version "0.3.0" version "0.3.1"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.0.tgz#407082d0d355ba565af24126fb6cb8e9115251fd" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz#440f1b70ccfaabc6b676d196239b138f8a2cfba5"
integrity sha512-wMDoBJ6uG4u4PNFh72Ty6t3EgfA91puCuAwKIazbQlci+ENb/UU9A3xG5lutjUIiXCIn1CY5L15r9LimiJyrSA== integrity sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w==
dependencies: dependencies:
"@babel/compat-data" "^7.13.11" "@babel/compat-data" "^7.13.11"
"@babel/helper-define-polyfill-provider" "^0.3.0" "@babel/helper-define-polyfill-provider" "^0.3.1"
semver "^6.1.1" semver "^6.1.1"
babel-plugin-polyfill-corejs3@^0.4.0: babel-plugin-polyfill-corejs3@^0.4.0:
@ -1361,7 +1374,7 @@ babel-plugin-polyfill-corejs3@^0.4.0:
"@babel/helper-define-polyfill-provider" "^0.3.0" "@babel/helper-define-polyfill-provider" "^0.3.0"
core-js-compat "^3.18.0" core-js-compat "^3.18.0"
babel-plugin-polyfill-corejs3@^0.5.0: babel-plugin-polyfill-corejs3@^0.5.2:
version "0.5.2" version "0.5.2"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz#aabe4b2fa04a6e038b688c5e55d44e78cd3a5f72" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz#aabe4b2fa04a6e038b688c5e55d44e78cd3a5f72"
integrity sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ== integrity sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ==
@ -1369,12 +1382,12 @@ babel-plugin-polyfill-corejs3@^0.5.0:
"@babel/helper-define-polyfill-provider" "^0.3.1" "@babel/helper-define-polyfill-provider" "^0.3.1"
core-js-compat "^3.21.0" core-js-compat "^3.21.0"
babel-plugin-polyfill-regenerator@^0.3.0: babel-plugin-polyfill-regenerator@^0.3.0, babel-plugin-polyfill-regenerator@^0.3.1:
version "0.3.0" version "0.3.1"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.0.tgz#9ebbcd7186e1a33e21c5e20cae4e7983949533be" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz#2c0678ea47c75c8cc2fbb1852278d8fb68233990"
integrity sha512-dhAPTDLGoMW5/84wkgwiLRwMnio2i1fUe53EuvtKMv0pn2p3S8OCoV1xAzfJPl0KOX7IB89s2ib85vbYiea3jg== integrity sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A==
dependencies: dependencies:
"@babel/helper-define-polyfill-provider" "^0.3.0" "@babel/helper-define-polyfill-provider" "^0.3.1"
babel-plugin-syntax-class-properties@^6.8.0: babel-plugin-syntax-class-properties@^6.8.0:
version "6.13.0" version "6.13.0"

View file

@ -26,7 +26,11 @@ type Config = {
}; };
export const register = (config?: Config) => { export const register = (config?: Config) => {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { if (
(process.env.NODE_ENV === "production" ||
process.env.REACT_APP_DEV_ENABLE_SW?.toLowerCase() === "true") &&
"serviceWorker" in navigator
) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {

View file

@ -38,6 +38,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -55,7 +56,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -211,6 +212,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -228,7 +230,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -388,6 +390,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -405,7 +408,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -726,6 +729,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -743,7 +747,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -1064,6 +1068,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -1081,7 +1086,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -1241,6 +1246,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -1258,7 +1264,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -1454,6 +1460,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -1471,7 +1478,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -1726,6 +1733,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -1743,7 +1751,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id1": true, "id1": true,
}, },
@ -2082,6 +2090,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -2099,7 +2108,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -2882,6 +2891,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -2899,7 +2909,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -3220,6 +3230,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -3237,7 +3248,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -3558,6 +3569,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -3575,7 +3587,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id1": true, "id1": true,
}, },
@ -3976,6 +3988,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -3993,7 +4006,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true, "id2": true,
@ -4254,6 +4267,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4271,7 +4285,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true, "id2": true,
@ -4613,6 +4627,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4630,7 +4645,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -4719,6 +4734,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4736,7 +4752,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -4803,6 +4819,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 100, "height": 100,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4820,7 +4837,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,

View file

@ -38,6 +38,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -55,7 +56,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -547,6 +548,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -564,7 +566,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -1062,6 +1064,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": false, "isBindingEnabled": false,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -1079,7 +1082,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -1922,6 +1925,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -1939,7 +1943,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -2143,6 +2147,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -2160,7 +2165,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id3": true, "id3": true,
@ -2649,6 +2654,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -2666,7 +2672,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -2925,6 +2931,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -2942,7 +2949,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -3102,6 +3109,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -3119,7 +3127,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id2": true, "id2": true,
}, },
@ -3591,6 +3599,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -3608,7 +3617,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -3848,6 +3857,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -3865,7 +3875,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -4069,6 +4079,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4086,7 +4097,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -4334,6 +4345,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4351,7 +4363,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id1": true, "id1": true,
}, },
@ -4609,6 +4621,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -4626,7 +4639,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id2": true, "id2": true,
}, },
@ -5031,6 +5044,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -5048,7 +5062,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -5355,6 +5369,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -5372,7 +5387,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -5654,6 +5669,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -5671,7 +5687,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -5881,6 +5897,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -5898,7 +5915,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -6058,6 +6075,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -6075,7 +6093,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -6559,6 +6577,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -6576,7 +6595,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -6907,6 +6926,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -6924,7 +6944,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -9146,6 +9166,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -9163,7 +9184,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true, "id2": true,
@ -9544,6 +9565,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -9561,7 +9583,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true, "id2": true,
@ -9820,6 +9842,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -9837,7 +9860,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true, "id2": true,
@ -10057,6 +10080,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -10074,7 +10098,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id2": true, "id2": true,
@ -10363,6 +10387,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -10380,7 +10405,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -10540,6 +10565,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -10557,7 +10583,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -10717,6 +10743,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -10734,7 +10761,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -10894,6 +10921,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -10911,7 +10939,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -11101,6 +11129,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -11118,7 +11147,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -11308,6 +11337,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -11325,7 +11355,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -11533,6 +11563,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -11550,7 +11581,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -11740,6 +11771,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -11757,7 +11789,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -11917,6 +11949,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -11934,7 +11967,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -12124,6 +12157,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -12141,7 +12175,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -12301,6 +12335,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -12318,7 +12353,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -12478,6 +12513,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -12495,7 +12531,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -12703,6 +12739,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -12720,7 +12757,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -13497,6 +13534,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -13514,7 +13552,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id3": true, "id3": true,
@ -13773,6 +13811,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -13790,7 +13829,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": -2.916666666666668, "scrollX": -2.916666666666668,
@ -13881,6 +13920,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -13898,7 +13938,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -13987,6 +14027,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -14004,7 +14045,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -14167,6 +14208,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -14184,7 +14226,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -14518,6 +14560,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -14535,7 +14578,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -14735,6 +14778,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -14752,7 +14796,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
"id1": true, "id1": true,
@ -15647,6 +15691,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -15664,7 +15709,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 60, "scrollX": 60,
@ -15753,6 +15798,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -15770,7 +15816,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id0": true, "id0": true,
}, },
@ -16592,6 +16638,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -16609,7 +16656,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id1": true, "id1": true,
"id2": true, "id2": true,
@ -17039,6 +17086,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -17056,7 +17104,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object { "previousSelectedElementIds": Object {
"id1": true, "id1": true,
}, },
@ -17338,6 +17386,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -17355,7 +17404,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 10, "scrollX": 10,
@ -17446,6 +17495,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -17463,7 +17513,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -17990,6 +18040,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -18007,7 +18058,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
@ -18096,6 +18147,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -18113,7 +18165,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,

View file

@ -50,6 +50,7 @@ jest.mock("socket.io-client", () => {
return { return {
close: () => {}, close: () => {},
on: () => {}, on: () => {},
once: () => {},
off: () => {}, off: () => {},
emit: () => {}, emit: () => {},
}; };
@ -77,7 +78,7 @@ describe("collaboration", () => {
]); ]);
expect(API.getStateHistory().length).toBe(1); expect(API.getStateHistory().length).toBe(1);
}); });
window.collab.openPortal(); window.collab.startCollaboration(null);
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1); expect(API.getStateHistory().length).toBe(1);

View file

@ -289,7 +289,7 @@ describe("contextMenu element", () => {
expect(copiedStyles).toBe("{}"); expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}"); expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles); const element = JSON.parse(copiedStyles)[0];
expect(element).toEqual(API.getSelectedElement()); expect(element).toEqual(API.getSelectedElement());
}); });
@ -329,7 +329,7 @@ describe("contextMenu element", () => {
}); });
let contextMenu = UI.queryContextMenu(); let contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles); const secondRect = JSON.parse(copiedStyles)[0];
expect(secondRect.id).toBe(h.elements[1].id); expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset(); mouse.reset();

View file

@ -38,6 +38,7 @@ Object {
"fileHandle": null, "fileHandle": null,
"gridSize": null, "gridSize": null,
"isBindingEnabled": true, "isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false, "isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
@ -53,7 +54,7 @@ Object {
}, },
"penDetected": false, "penDetected": false,
"penMode": false, "penMode": false,
"pendingImageElement": null, "pendingImageElementId": null,
"previousSelectedElementIds": Object {}, "previousSelectedElementIds": Object {},
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,

View file

@ -48,6 +48,8 @@ export type Collaborator = {
// The url of the collaborator's avatar, defaults to username intials // The url of the collaborator's avatar, defaults to username intials
// if not present // if not present
avatarUrl?: string; avatarUrl?: string;
// user id. If supplied, we'll filter out duplicates when rendering user avatars.
id?: string;
}; };
export type DataURL = string & { _brand: "DataURL" }; export type DataURL = string & { _brand: "DataURL" };
@ -160,6 +162,7 @@ export type AppState = {
offsetLeft: number; offsetLeft: number;
isLibraryOpen: boolean; isLibraryOpen: boolean;
isLibraryMenuDocked: boolean;
fileHandle: FileSystemHandle | null; fileHandle: FileSystemHandle | null;
collaborators: Map<string, Collaborator>; collaborators: Map<string, Collaborator>;
showStats: boolean; showStats: boolean;
@ -174,7 +177,7 @@ export type AppState = {
data: Spreadsheet; data: Spreadsheet;
}; };
/** imageElement waiting to be placed on canvas */ /** imageElement waiting to be placed on canvas */
pendingImageElement: NonDeleted<ExcalidrawImageElement> | null; pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor"; showHyperlinkPopup: false | "info" | "editor";
}; };
@ -289,7 +292,10 @@ export interface ExcalidrawProps {
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
) => JSX.Element; ) => JSX.Element;
UIOptions?: UIOptions; UIOptions?: {
dockedSidebarBreakpoint?: number;
canvasActions?: CanvasActions;
};
detectScroll?: boolean; detectScroll?: boolean;
handleKeyboardGlobally?: boolean; handleKeyboardGlobally?: boolean;
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>; onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
@ -347,18 +353,18 @@ type CanvasActions = {
saveAsImage?: boolean; saveAsImage?: boolean;
}; };
export type UIOptions = { export type AppProps = Merge<
canvasActions?: CanvasActions; ExcalidrawProps,
}; {
UIOptions: {
export type AppProps = ExcalidrawProps & { canvasActions: Required<CanvasActions> & { export: ExportOpts };
UIOptions: { dockedSidebarBreakpoint?: number;
canvasActions: Required<CanvasActions> & { export: ExportOpts }; };
}; detectScroll: boolean;
detectScroll: boolean; handleKeyboardGlobally: boolean;
handleKeyboardGlobally: boolean; isCollaborating: boolean;
isCollaborating: boolean; }
}; >;
/** A subset of App class properties that we need to use elsewhere /** A subset of App class properties that we need to use elsewhere
* in the app, eg Manager. Factored out into a separate type to keep DRY. */ * in the app, eg Manager. Factored out into a separate type to keep DRY. */
@ -375,7 +381,8 @@ export type AppClassProperties = {
} }
>; >;
files: BinaryFiles; files: BinaryFiles;
deviceType: App["deviceType"]; device: App["device"];
scene: App["scene"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@ -470,7 +477,9 @@ export type ExcalidrawImperativeAPI = {
resetCursor: InstanceType<typeof App>["resetCursor"]; resetCursor: InstanceType<typeof App>["resetCursor"];
}; };
export type DeviceType = { export type Device = Readonly<{
isSmScreen: boolean;
isMobile: boolean; isMobile: boolean;
isTouchScreen: boolean; isTouchScreen: boolean;
}; canDeviceFitSidebar: boolean;
}>;

View file

@ -668,3 +668,16 @@ export const isPromiseLike = (
"finally" in value "finally" in value
); );
}; };
export const queryFocusableElements = (container: HTMLElement | null) => {
const focusableElements = container?.querySelectorAll<HTMLElement>(
"button, a, input, select, textarea, div[tabindex], label[tabindex]",
);
return focusableElements
? Array.from(focusableElements).filter(
(element) =>
element.tabIndex > -1 && !(element as HTMLInputElement).disabled,
)
: [];
};

View file

@ -27,6 +27,16 @@
{ {
"source": "/webex/:match*", "source": "/webex/:match*",
"destination": "https://for-webex.excalidraw.com" "destination": "https://for-webex.excalidraw.com"
},
{
"source": "/:path*",
"has": [
{
"type": "host",
"value": "vscode.excalidraw.com"
}
],
"destination": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor"
} }
] ]
} }

579
yarn.lock

File diff suppressed because it is too large Load diff