Compare commits

..

1 Commits

Author SHA1 Message Date
pomdtr
ef7330243f export debounce util in excalidraw package 2022-04-24 19:52:07 +00:00
192 changed files with 7423 additions and 10020 deletions

View File

@@ -11,12 +11,3 @@ REACT_APP_WS_SERVER_URL=http://localhost:3002
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"}'
# 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

@@ -13,5 +13,3 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
REACT_APP_PLUS_APP=https://app.excalidraw.com

37
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
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 next
name: Auto release @excalidraw/excalidraw-next
on:
push:
branches:

View File

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

View File

@@ -22,23 +22,23 @@
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1",
"@testing-library/react": "12.1.2",
"@tldraw/vec": "1.4.3",
"@types/jest": "27.4.0",
"@types/pica": "5.1.3",
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"browser-fs-access": "0.24.1",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"nanoid": "3.1.32",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.0.16",
@@ -51,7 +51,7 @@
"react-dom": "17.0.2",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.51.0",
"sass": "1.49.7",
"socket.io-client": "2.3.1",
"typescript": "4.5.5"
},
@@ -59,19 +59,19 @@
"@excalidraw/eslint-config": "1.0.0",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/lodash.throttle": "4.1.7",
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.3",
"@types/resize-observer-browser": "0.1.7",
"@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"dotenv": "10.0.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1",
"husky": "7.0.4",
"jest-canvas-mock": "2.4.0",
"jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.7",
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0"
"prettier": "2.5.1",
"rewire": "5.0.0"
},
"resolutions": {
"@typescript-eslint/typescript-estree": "5.10.2"
@@ -94,8 +94,7 @@
"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:version": "node ./scripts/build-version.js",
"build:prebuild": "node ./scripts/prebuild.js",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"build": "yarn build:app && yarn build:version",
"eject": "react-scripts eject",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
@@ -113,8 +112,6 @@
"test:typecheck": "tsc",
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
"test": "yarn test:app",
"autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js",
"release": "node scripts/release.js"
"autorelease": "node scripts/autorelease.js"
}
}

View File

@@ -52,25 +52,6 @@
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
// Redirect only the bare root path, so link/room/library urls are not
// redirected.
//
// Putting into index.html for best performance (can't redirect on server
// due to location.hash checks).
if (
window.location.pathname === "/" &&
!window.location.hash &&
!window.location.search &&
// if its present redirect
document.cookie.includes("excplus-autoredirect=true")
) {
window.location.href = "https://app.excalidraw.com";
}
</script>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<!-- Excalidraw version -->
@@ -98,22 +79,6 @@
/>
<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>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.

View File

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

View File

@@ -36,7 +36,6 @@ const crowdinMap = {
"ru-RU": "en-ru",
"si-LK": "en-silk",
"sk-SK": "en-sk",
"sl-SI": "en-sl",
"sv-SE": "en-sv",
"ta-IN": "en-ta",
"tr-TR": "en-tr",
@@ -48,8 +47,6 @@ const crowdinMap = {
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
"vi-vn": "en-vi",
"mr-in": "en-mr",
};
const flags = {
@@ -89,7 +86,6 @@ const flags = {
"ru-RU": "🇷🇺",
"si-LK": "🇱🇰",
"sk-SK": "🇸🇰",
"sl-SI": "🇸🇮",
"sv-SE": "🇸🇪",
"ta-IN": "🇮🇳",
"tr-TR": "🇹🇷",
@@ -97,9 +93,6 @@ const flags = {
"zh-CN": "🇨🇳",
"zh-HK": "🇭🇰",
"zh-TW": "🇹🇼",
"eu-ES": "🇪🇦",
"vi-VN": "🇻🇳",
"mr-IN": "🇮🇳",
};
const languages = {
@@ -140,7 +133,6 @@ const languages = {
"ru-RU": "Русский",
"si-LK": "සිංහල",
"sk-SK": "Slovenčina",
"sl-SI": "Slovenščina",
"sv-SE": "Svenska",
"ta-IN": "Tamil",
"tr-TR": "Türkçe",
@@ -148,8 +140,6 @@ const languages = {
"zh-CN": "简体中文",
"zh-HK": "繁體中文 (香港)",
"zh-TW": "繁體中文",
"vi-VN": "Tiếng Việt",
"mr-IN": "मराठी",
};
const percentages = fs.readFileSync(

View File

@@ -1,20 +0,0 @@
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,37 +0,0 @@
const fs = require("fs");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const updatePackageVersion = (nextVersion) => {
const pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const prerelease = async (nextVersion) => {
try {
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
console.info("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
prerelease(nextVersion);

View File

@@ -1,44 +1,39 @@
const fs = require("fs");
const { execSync } = require("child_process");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
const updateReadme = require("./updateReadme");
const updateChangelog = require("./updateChangelog");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage);
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 pkg = require(excalidrawPackage);
pkg.version = nextVersion;
const content = `${JSON.stringify(pkg, null, 2)}\n`;
fs.writeFileSync(excalidrawPackage, content, "utf-8");
};
const publish = () => {
const release = async (nextVersion) => {
try {
execSync(`yarn --frozen-lockfile`);
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
execSync(`yarn --cwd ${excalidrawDir} publish`);
updateReadme();
await updateChangelog(nextVersion);
updatePackageVersion(nextVersion);
await exec(`git add -u`);
await exec(
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
/* eslint-disable no-console */
console.log("Done!");
} catch (error) {
console.error(error);
process.exit(1);
}
};
const release = () => {
updateReadme();
console.info("Note for stable readme removed");
publish();
console.info(`Published ${pkg.version}!`);
// revert readme after release
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Readme reverted");
};
release();
const nextVersion = process.argv.slice(2)[0];
if (!nextVersion) {
console.error("Pass the next version to release!");
process.exit(1);
}
release(nextVersion);

27
scripts/updateReadme.js Normal file
View File

@@ -0,0 +1,27 @@
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

@@ -25,9 +25,9 @@ export const actionAddToLibrary = register({
}
return app.library
.getLatestLibrary()
.loadLibrary()
.then((items) => {
return app.library.setLibrary([
return app.library.saveLibrary([
{
id: randomId(),
status: "unpublished",
@@ -42,7 +42,7 @@ export const actionAddToLibrary = register({
commitToHistory: false,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
toastMessage: t("toast.addedToLibrary"),
},
};
})

View File

@@ -11,7 +11,7 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
@@ -304,28 +304,21 @@ export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "eraser",
lastActiveToolBeforeEraser: appState.activeTool,
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
activeTool: {
...appState.activeTool,
type: isEraserActive(appState)
? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
: "eraser",
lastActiveToolBeforeEraser:
appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
? null
: appState.activeTool.type,
},
},
commitToHistory: true,
};

View File

@@ -15,9 +15,7 @@ export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files);
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
return {
commitToHistory: false,
@@ -107,16 +105,14 @@ export const actionCopyAsPng = register({
return {
appState: {
...appState,
toast: {
message: t("toast.copyToClipboardAsPng", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
toastMessage: t("toast.copyToClipboardAsPng", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
commitToHistory: false,
};

View File

@@ -12,7 +12,6 @@ import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@@ -135,7 +134,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
activeTool: { ...appState.activeTool, type: "selection" },
multiElement: null,
},
commitToHistory: isSomeElementSelected(

View File

@@ -3,7 +3,7 @@ import {
DistributeVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import { distributeElements, Distribution } from "../disitrubte";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";

View File

@@ -128,15 +128,12 @@ const duplicateElements = (
{
...appState,
selectedGroupIds: {},
selectedElementIds: newElements.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
selectedElementIds: newElements.reduce((acc, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {} as any),
},
getNonDeletedElements(finalElements),
),

View File

@@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import { useDeviceType } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
@@ -144,15 +144,13 @@ export const actionSaveToActiveFile = register({
appState: {
...appState,
fileHandle,
toast: fileHandleExists
? {
message: fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved"),
}
toastMessage: fileHandleExists
? fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved")
: null,
},
};
@@ -206,7 +204,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().isMobile}
showAriaLabel={useDeviceType().isMobile}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"
@@ -250,7 +248,7 @@ export const actionLoadScene = register({
icon={load}
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useDevice().isMobile}
showAriaLabel={useDeviceType().isMobile}
onClick={updateData}
data-testid="load-button"
/>

View File

@@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils";
import { resetCursor } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -14,12 +14,11 @@ import {
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement } from "../element/typeChecks";
import { AppState } from "../types";
export const actionFinalize = register({
name: "finalize",
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
@@ -50,12 +49,8 @@ export const actionFinalize = register({
let newElements = elements;
const pendingImageElement =
appState.pendingImageElementId &&
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
if (appState.pendingImageElement) {
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) {
@@ -142,20 +137,6 @@ export const actionFinalize = register({
resetCursor(canvas);
}
let activeTool: AppState["activeTool"];
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return {
elements: newElements,
appState: {
@@ -166,7 +147,14 @@ export const actionFinalize = register({
appState.activeTool.type === "freedraw") &&
multiPointElement
? appState.activeTool
: activeTool,
: {
...appState.activeTool,
type:
appState.activeTool.type === "eraser" &&
appState.activeTool.lastActiveToolBeforeEraser
? appState.activeTool.lastActiveToolBeforeEraser
: "selection",
},
draggingElement: null,
multiElement: null,
editingElement: null,
@@ -181,7 +169,7 @@ export const actionFinalize = register({
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
pendingImageElementId: null,
pendingImageElement: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
};

View File

@@ -1,4 +1,4 @@
import { getClientColors } from "../clients";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
@@ -31,18 +31,28 @@ export const actionGoToCollaborator = register({
};
},
PanelComponent: ({ appState, updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator];
const clientId: string | undefined = data?.id;
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
/>
>
{shortName}
</Avatar>
);
},
});

View File

@@ -485,14 +485,10 @@ export const actionChangeOpacity = register({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(el) =>
newElementWith(el, {
opacity: value,
}),
true,
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
opacity: value,
}),
),
appState: { ...appState, currentItemOpacity: value },
commitToHistory: true,
@@ -507,6 +503,20 @@ export const actionChangeOpacity = register({
max="100"
step="10"
onChange={(event) => updateData(+event.target.value)}
onWheel={(event) => {
event.stopPropagation();
const target = event.target as HTMLInputElement;
const STEP = 10;
const MAX = 100;
const MIN = 0;
const value = +target.value;
if (event.deltaY < 0 && value < MAX) {
updateData(value + STEP);
} else if (event.deltaY > 0 && value > MIN) {
updateData(value - STEP);
}
}}
value={
getFormValue(
elements,

View File

@@ -2,7 +2,6 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types";
export const actionSelectAll = register({
name: "selectAll",
@@ -16,19 +15,16 @@ export const actionSelectAll = register({
{
...appState,
editingGroupId: null,
selectedElementIds: elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
!element.locked
) {
map[element.id] = true;
}
return map;
},
{},
),
selectedElementIds: elements.reduce((map, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId) &&
element.locked === false
) {
map[element.id] = true;
}
return map;
}, {} as any),
},
getNonDeletedElements(elements),
),

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,6 @@ export const getDefaultAppState = (): Omit<
editingLinearElement: null,
activeTool: {
type: "selection",
customType: null,
locked: false,
lastActiveToolBeforeEraser: null,
},
@@ -58,7 +57,6 @@ export const getDefaultAppState = (): Omit<
gridSize: null,
isBindingEnabled: true,
isLibraryOpen: false,
isLibraryMenuDocked: false,
isLoading: false,
isResizing: false,
isRotating: false,
@@ -81,14 +79,14 @@ export const getDefaultAppState = (): Omit<
showStats: false,
startBoundElement: null,
suggestedBindings: [],
toast: null,
toastMessage: null,
viewBackgroundColor: oc.white,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false,
pendingImageElementId: null,
pendingImageElement: null,
showHyperlinkPopup: false,
};
};
@@ -147,8 +145,7 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: true, export: false, server: false },
isLibraryMenuDocked: { browser: true, export: false, server: false },
isLibraryOpen: { browser: false, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
@@ -173,13 +170,13 @@ const APP_STATE_STORAGE_CONF = (<
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
toast: { browser: false, export: false, server: false },
toastMessage: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
});

View File

@@ -1,121 +0,0 @@
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {
it.each<[string, number]>([
["1", 1],
["0", 0],
["-1", -1],
["0.1", 0.1],
[".1", 0.1],
["1.", 1],
["424.", 424],
["$1", 1],
["-.1", -0.1],
["-$1", -1],
["$-1", -1],
])("should correctly identify %s as numbers", (given, expected) => {
expect(tryParseNumber(given)).toEqual(expected);
});
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
"should correctly identify %s as not a number",
(given) => {
expect(tryParseNumber(given)).toBeNull();
},
);
});
describe("tryParseCells", () => {
it("Successfully parses a spreadsheet", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("Uses the second column as the label if it is not a number", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("treats the first column as labels if both columns are numbers", () => {
const spreadsheet = [
["time", "value"],
["01", "61"],
["02", "-60"],
["03", "85"],
["04", "-67"],
["05", "54"],
["06", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
});
});

View File

@@ -29,24 +29,18 @@ type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET; reason: string }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
/**
* @private exported for testing
*/
export const tryParseNumber = (s: string): number | null => {
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
const tryParseNumber = (s: string): number | null => {
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
if (!match) {
return null;
}
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
return parseFloat(match[1].replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
/**
* @private exported for testing
*/
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
@@ -77,16 +71,13 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
};
}
const labelColumnNumeric = isNumericColumn(cells, 0);
const valueColumnNumeric = isNumericColumn(cells, 1);
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
if (!labelColumnNumeric && !valueColumnNumeric) {
if (!isNumericColumn(cells, valueColumnIndex)) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
? [0, 1]
: [1, 0];
const labelColumnIndex = (valueColumnIndex + 1) % 2;
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
const rows = hasHeader ? cells.slice(1) : cells;

View File

@@ -2,6 +2,7 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
@@ -11,7 +12,7 @@ import { isPromiseLike } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: readonly NonDeletedExcalidrawElement[];
elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
};
@@ -56,20 +57,19 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null,
files: BinaryFiles,
) => {
// select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements,
files: files
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;

View File

@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import { useDeviceType } from "../components/App";
import {
canChangeSharpness,
canHaveArrowheads,
@@ -15,12 +15,7 @@ import {
} from "../scene";
import { SHAPES } from "../shapes";
import { AppState, Zoom } from "../types";
import {
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
@@ -52,7 +47,7 @@ export const SelectedShapeActions = ({
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement);
const device = useDevice();
const deviceType = useDeviceType();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
@@ -177,8 +172,8 @@ export const SelectedShapeActions = ({
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!device.isMobile && renderAction("duplicateSelection")}
{!device.isMobile && renderAction("deleteSelectedElements")}
{!deviceType.isMobile && renderAction("duplicateSelection")}
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
@@ -234,9 +229,7 @@ export const ShapesSwitcher = ({
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
const nextActiveTool = { ...activeTool, type: value };
setAppState({
activeTool: nextActiveTool,
multiElement: null,

File diff suppressed because it is too large Load Diff

View File

@@ -12,11 +12,5 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
}
}

View File

@@ -1,36 +1,20 @@
import "./Avatar.scss";
import React, { useState } from "react";
import { getClientInitials } from "../clients";
import React from "react";
type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
name: string;
src?: string;
};
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name);
const [error, setError] = useState(false);
const loadImg = !error && src;
const style = loadImg
? undefined
: { background: color, border: `1px solid ${border}` };
return (
<div className="Avatar" style={style} onClick={onClick}>
{loadImg ? (
<img
className="Avatar-img"
src={src}
alt={shortName}
referrerPolicy="no-referrer"
onError={() => setError(true)}
/>
) : (
shortName
)}
</div>
);
};
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
<div
className="Avatar"
style={{ background: color, border: `1px solid ${border}` }}
onClick={onClick}
>
{children}
</div>
);

View File

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

View File

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

View File

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

View File

@@ -128,33 +128,45 @@ const Picker = ({
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => {
let handled = false;
if (isArrowKey(event.key)) {
handled = true;
if (event.key === KEYS.TAB) {
const { activeElement } = document;
if (event.shiftKey) {
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 isRTL = getLanguage().rtl;
let isCustom = false;
let index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--default")
?.children,
gallery!.current!.querySelector(".color-picker-content--default")!
.children,
activeElement,
);
if (index === -1) {
index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--canvas-colors")
?.children,
gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!.children,
activeElement,
);
if (index !== -1) {
isCustom = true;
}
}
const parentElement = isCustom
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
: gallery.current?.querySelector(".color-picker-content--default");
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
if (parentElement && index !== -1) {
const length = parentElement.children.length - (showInput ? 1 : 0);
if (index !== -1) {
const length = parentSelector!.children.length - (showInput ? 1 : 0);
const nextIndex =
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
? (index + 1) % length
@@ -165,38 +177,30 @@ const Picker = ({
: !isCustom && event.key === KEYS.ARROW_UP
? (length + index - 5) % length
: index;
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
}
event.preventDefault();
} else if (
keyBindings.includes(event.key.toLowerCase()) &&
!event[KEYS.CTRL_OR_CMD] &&
!event.altKey &&
!isWritableElement(event.target)
) {
handled = true;
const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS;
const parentElement = isCustom
? gallery?.current?.querySelector(
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)
: gallery?.current?.querySelector(".color-picker-content--default");
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
(
parentElement?.children[actualIndex] as HTMLElement | undefined
)?.focus();
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
handled = true;
event.preventDefault();
onClose();
}
if (handled) {
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
};
const renderColors = (colors: Array<string>, custom: boolean = false) => {
@@ -260,8 +264,7 @@ const Picker = ({
gallery.current = el;
}
}}
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
tabIndex={0}
>
<div className="color-picker-content--default">
{renderColors(colors)}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useDevice } from "./App";
import { useDeviceType } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types";
@@ -170,9 +170,7 @@ const ImageExportModal = ({
<Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")}
</Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>
{t("buttons.scale")}
</p>
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
</div>
<div
style={{
@@ -252,7 +250,7 @@ export const ImageExportDialog = ({
icon={exportImage}
type="button"
aria-label={t("buttons.exportImage")}
showAriaLabel={useDevice().isMobile}
showAriaLabel={useDeviceType().isMobile}
title={t("buttons.exportImage")}
/>
{modalIsShown && (

View File

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

View File

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

View File

@@ -1,63 +1,9 @@
@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 {
.layer-ui__wrapper.animate {
transition: width 0.1s ease-in-out;
}
.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);
&__top-right {
display: flex;
}

View File

@@ -1,7 +1,7 @@
import clsx from "clsx";
import React, { useCallback } from "react";
import { ActionManager } from "../actions/manager";
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { CLASSES } from "../constants";
import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
@@ -25,8 +25,9 @@ import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
@@ -36,10 +37,7 @@ import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import { actionToggleZenMode } from "../actions";
import { useDeviceType } from "../components/App";
interface LayerUIProps {
actionManager: ActionManager;
@@ -52,13 +50,18 @@ interface LayerUIProps {
onLockToggle: () => void;
onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean;
showThemeBtn: boolean;
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
@@ -66,6 +69,7 @@ interface LayerUIProps {
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
}
const LayerUI = ({
actionManager,
appState,
@@ -77,12 +81,14 @@ const LayerUI = ({
onLockToggle,
onPenModeToggle,
onInsertElements,
zenModeEnabled,
showExitZenModeBtn,
showThemeBtn,
toggleZenMode,
isCollaborating,
renderTopRightUI,
renderCustomFooter,
renderCustomStats,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
focusContainer,
@@ -90,7 +96,7 @@ const LayerUI = ({
id,
onImageAction,
}: LayerUIProps) => {
const device = useDevice();
const deviceType = useDeviceType();
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -166,7 +172,7 @@ const LayerUI = ({
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
"transition-left": zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
@@ -187,7 +193,7 @@ const LayerUI = ({
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
"transition-left": zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
@@ -227,7 +233,7 @@ const LayerUI = ({
<Section
heading="selectedShapeActions"
className={clsx("zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
"transition-left": zenModeEnabled,
})}
>
<Island
@@ -271,9 +277,7 @@ const LayerUI = ({
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
onClose={closeLibrary}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onInsertShape={onInsertElements}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
@@ -297,34 +301,32 @@ const LayerUI = ({
<div className="App-menu App-menu_top">
<Stack.Col
gap={4}
className={clsx({
"disable-pointerEvents": appState.zenModeEnabled,
})}
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
>
{appState.viewModeEnabled
{viewModeEnabled
? renderViewModeCanvasActions()
: renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
{!appState.viewModeEnabled && (
{!viewModeEnabled && (
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
"zen-mode": zenModeEnabled,
})}
>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
zenModeEnabled={zenModeEnabled}
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
zenModeEnabled={appState.zenModeEnabled}
zenModeEnabled={zenModeEnabled}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
title={t("toolBar.lock")}
@@ -332,13 +334,13 @@ const LayerUI = ({
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
"zen-mode": zenModeEnabled,
})}
>
<HintViewer
appState={appState}
elements={elements}
isMobile={device.isMobile}
isMobile={deviceType.isMobile}
/>
{heading}
<Stack.Row gap={1}>
@@ -360,6 +362,7 @@ const LayerUI = ({
setAppState={setAppState}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
@@ -368,15 +371,27 @@ const LayerUI = ({
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
{
"transition-right": appState.zenModeEnabled,
"transition-right": zenModeEnabled,
},
)}
>
<UserList
collaborators={appState.collaborators}
actionManager={actionManager}
/>
{renderTopRightUI?.(device.isMobile, appState)}
<UserList>
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(deviceType.isMobile, appState)}
</div>
</div>
</FixedSideContainer>
@@ -393,8 +408,7 @@ const LayerUI = ({
className={clsx(
"layer-ui__wrapper__footer-left zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
},
)}
>
@@ -406,12 +420,12 @@ const LayerUI = ({
zoom={appState.zoom}
/>
</Island>
{!appState.viewModeEnabled && (
{!viewModeEnabled && (
<>
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
@@ -421,20 +435,20 @@ const LayerUI = ({
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</>
)}
{!appState.viewModeEnabled &&
{!viewModeEnabled &&
appState.multiElement &&
device.isTouchScreen && (
deviceType.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
@@ -448,7 +462,7 @@ const LayerUI = ({
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
zenModeEnabled,
},
)}
>
@@ -458,7 +472,7 @@ const LayerUI = ({
className={clsx(
"layer-ui__wrapper__footer-right zen-mode-transition",
{
"transition-right disable-pointerEvents": appState.zenModeEnabled,
"transition-right disable-pointerEvents": zenModeEnabled,
},
)}
>
@@ -468,7 +482,7 @@ const LayerUI = ({
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={() => actionManager.executeAction(actionToggleZenMode)}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
@@ -507,24 +521,7 @@ const LayerUI = ({
</>
);
const renderStats = () => {
if (!appState.showStats) {
return null;
}
return (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
);
};
return device.isMobile ? (
return deviceType.isMobile ? (
<>
{dialogs}
<MobileMenu
@@ -541,51 +538,37 @@ const LayerUI = ({
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderStats={renderStats}
/>
</>
) : (
<>
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement &&
!isTextElement(appState.editingElement)),
})}
style={
appState.isLibraryOpen &&
appState.isLibraryMenuDocked &&
device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderStats()}
{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
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)),
})}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</>
</div>
);
};

View File

@@ -3,8 +3,6 @@ import clsx from "clsx";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
const LIBRARY_ICON = (
<svg viewBox="0 0 576 512">
@@ -20,7 +18,6 @@ export const LibraryButton: React.FC<{
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
return (
<label
className={clsx(
@@ -37,19 +34,7 @@ export const LibraryButton: React.FC<{
type="checkbox"
name="editor-library"
onChange={(event) => {
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"})`,
);
}
setAppState({ isLibraryOpen: event.target.checked });
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}

View File

@@ -2,6 +2,7 @@
.excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
@@ -10,26 +11,19 @@
display: flex;
align-items: center;
width: 100%;
margin: 2px 0 15px 0;
.Spinner {
margin-right: 1rem;
}
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
}
}
.layer-ui__sidebar {
.layer-ui__library {
padding: 0;
height: 100%;
}
.library-menu-items-container {
height: 100%;
width: 100%;
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
}
}
@@ -67,38 +61,4 @@
}
}
}
.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

@@ -25,11 +25,11 @@ import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
import { useDevice } from "./App";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
@@ -77,7 +77,7 @@ const LibraryMenuWrapper = forwardRef<
export const LibraryMenu = ({
onClose,
onInsertLibraryItems,
onInsertShape,
pendingElements,
onAddToLibrary,
theme,
@@ -91,7 +91,7 @@ export const LibraryMenu = ({
}: {
pendingElements: LibraryItem["elements"];
onClose: () => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
@@ -104,30 +104,17 @@ export const LibraryMenu = ({
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const device = useDevice();
useOnClickOutside(
ref,
useCallback(
(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],
),
);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
onClose();
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
) {
if (event.key === KEYS.ESCAPE) {
onClose();
}
};
@@ -135,7 +122,7 @@ export const LibraryMenu = ({
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
}, [onClose]);
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
@@ -152,7 +139,7 @@ export const LibraryMenu = ({
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
library.saveLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
@@ -183,7 +170,7 @@ export const LibraryMenu = ({
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
library.saveLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
},
@@ -233,15 +220,16 @@ export const LibraryMenu = ({
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
library.saveLibrary(nextLibItems);
},
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
);
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
if (libraryItemsData.status === "loading") {
return (
<LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message">
@@ -267,7 +255,7 @@ export const LibraryMenu = ({
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
library.saveLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
@@ -276,7 +264,6 @@ export const LibraryMenu = ({
)}
{publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
@@ -284,17 +271,56 @@ export const LibraryMenu = ({
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertLibraryItems={onInsertLibraryItems}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
appState={appState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onSelectItems={(ids) => setSelectedItems(ids)}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItemsData.libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItemsData.libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setSelectedItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>

View File

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

View File

@@ -1,6 +1,6 @@
import { chunk } from "lodash";
import React, { useCallback, useState } from "react";
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
import { useCallback, useState } from "react";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import Library from "../data/library";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n";
@@ -11,57 +11,48 @@ import {
LibraryItem,
LibraryItems,
} from "../types";
import { arrayToMap, muteFSAbortError } from "../utils";
import { useDevice } from "./App";
import { muteFSAbortError } from "../utils";
import { useDeviceType } from "./App";
import ConfirmDialog from "./ConfirmDialog";
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
import { MIME_TYPES, VERSIONS } from "../constants";
import Spinner from "./Spinner";
import { fileOpen } from "../data/filesystem";
import { SidebarLockButton } from "./SidebarLockButton";
import { trackEvent } from "../analytics";
import { VERSIONS } from "../constants";
const LibraryMenuItems = ({
isLoading,
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
onInsertLibraryItems,
onInsertShape,
pendingElements,
theme,
setAppState,
appState,
libraryReturnUrl,
library,
files,
id,
selectedItems,
onSelectItems,
onToggle,
onPublish,
resetLibrary,
}: {
isLoading: boolean;
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
appState: AppState;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onPublish: () => void;
resetLibrary: () => void;
}) => {
@@ -93,7 +84,9 @@ const LibraryMenuItems = ({
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const device = useDevice();
const isMobile = useDeviceType().isMobile;
const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length;
const items = itemsSelected
@@ -104,34 +97,19 @@ const LibraryMenuItems = ({
: t("buttons.resetLibrary");
return (
<div className="library-actions">
{!itemsSelected && (
{(!itemsSelected || !isMobile) && (
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={async () => {
try {
await library.updateLibrary({
libraryItems: fileOpen({
description: "Excalidraw library files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
/*
extensions: [".json", ".excalidrawlib"],
*/
}),
merge: true,
openLibraryMenu: true,
onClick={() => {
importLibraryFromJSON(library)
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
} catch (error: any) {
if (error?.name === "AbortError") {
console.warn(error);
return;
}
setAppState({ errorMessage: t("errors.importLibraryError") });
}
}}
className="library-actions--load"
/>
@@ -147,7 +125,7 @@ const LibraryMenuItems = ({
onClick={async () => {
const libraryItems = itemsSelected
? items
: await library.getLatestLibrary();
: await library.loadLibrary();
saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError)
.catch((error) => {
@@ -179,7 +157,7 @@ const LibraryMenuItems = ({
</ToolButton>
</>
)}
{itemsSelected && (
{itemsSelected && !isPublished && (
<Tooltip label={t("hints.publishLibrary")}>
<ToolButton
type="button"
@@ -189,7 +167,7 @@ const LibraryMenuItems = ({
className="library-actions--publish"
onClick={onPublish}
>
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
@@ -198,89 +176,17 @@ const LibraryMenuItems = ({
</ToolButton>
</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>
);
};
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
const CELLS_PER_ROW = isMobile ? 4 : 6;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const onItemSelectToggle = (
id: LibraryItem["id"],
event: React.MouseEvent,
) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
}
};
const getInsertedElements = (id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements;
};
const isPublished = selectedItems.some(
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
);
const createLibraryItemCompo = (params: {
item:
@@ -302,12 +208,8 @@ const LibraryMenuItems = ({
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={onItemSelectToggle}
onDrag={(id, event) => {
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
onToggle={(id, event) => {
onToggle(id, event);
}}
/>
</Stack.Col>
@@ -327,7 +229,7 @@ const LibraryMenuItems = ({
if (item.id) {
return createLibraryItemCompo({
item,
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
onClick: () => onInsertShape(item.elements),
key: item.id,
});
}
@@ -366,192 +268,49 @@ const LibraryMenuItems = ({
});
};
const unpublishedItems = libraryItems.filter(
(item) => item.status !== "published",
);
const publishedItems = libraryItems.filter(
(item) => item.status === "published",
);
const unpublishedItems = [
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...libraryItems.filter((item) => item.status !== "published"),
];
const renderLibraryHeader = () => {
return (
<>
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
{device.canDeviceFitSidebar && (
<>
<div className="layer-ui__sidebar-lock-button">
<SidebarLockButton
checked={appState.isLibraryMenuDocked}
onChange={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.add("animate");
const nextState = !appState.isLibraryMenuDocked;
setAppState({
isLibraryMenuDocked: nextState,
});
trackEvent(
"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 (
return (
<div className="library-menu-items-container">
{showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
<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>
<Stack.Col
className="library-menu-items-container__items"
align="start"
gap={1}
style={{
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0,
}}
>
<>
<div className="separator">
{(pendingElements.length > 0 ||
unpublishedItems.length > 0 ||
publishedItems.length > 0) && (
<div>{t("labels.personalLib")}</div>
)}
{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.personalLib")}</div>
{renderLibrarySection(unpublishedItems)}
</>
<>
{(publishedItems.length > 0 ||
(!device.isMobile &&
(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}
<div className="separator">{t("labels.excalidrawLib")} </div>
{renderLibrarySection(publishedItems)}
</>
</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>
);
};

View File

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

View File

@@ -1,7 +1,8 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App";
import { MIME_TYPES } from "../constants";
import { useDeviceType } from "../components/App";
import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss";
@@ -28,7 +29,6 @@ export const LibraryUnit = ({
onClick,
selected,
onToggle,
onDrag,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
@@ -37,7 +37,6 @@ export const LibraryUnit = ({
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -67,7 +66,7 @@ export const LibraryUnit = ({
}, [elements, files]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const isMobile = useDeviceType().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div>
);
@@ -100,12 +99,11 @@ export const LibraryUnit = ({
: undefined
}
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
}
setIsHovered(false);
onDrag(id, event);
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
JSON.stringify(elements),
);
}}
/>
{adder}

View File

@@ -32,17 +32,14 @@ type MobileMenuProps = {
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderStats: () => JSX.Element | null;
};
export const MobileMenu = ({
@@ -59,10 +56,10 @@ export const MobileMenu = ({
canvas,
isCollaborating,
renderCustomFooter,
viewModeEnabled,
showThemeBtn,
onImageAction,
renderTopRightUI,
renderStats,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@@ -123,7 +120,7 @@ export const MobileMenu = ({
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (appState.viewModeEnabled) {
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
@@ -149,7 +146,7 @@ export const MobileMenu = ({
};
const renderCanvasActions = () => {
if (appState.viewModeEnabled) {
if (viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
@@ -183,8 +180,7 @@ export const MobileMenu = ({
};
return (
<>
{!appState.viewModeEnabled && renderToolbar()}
{renderStats()}
{!viewModeEnabled && renderToolbar()}
<div
className="App-bottom-bar"
style={{
@@ -203,18 +199,27 @@ export const MobileMenu = ({
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile
collaborators={appState.collaborators}
actionManager={actionManager}
/>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(
([_, client]) => Object.keys(client).length !== 0,
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</React.Fragment>
))}
</UserList>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
!viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
@@ -227,20 +232,18 @@ export const MobileMenu = ({
) : null}
<footer className="App-toolbar">
{renderAppToolbar()}
{appState.scrolledOutside &&
!appState.openMenu &&
!appState.isLibraryOpen && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island>
</div>

View File

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

View File

@@ -2,6 +2,5 @@
.popover {
position: absolute;
z-index: 10;
padding: 5px 0 5px;
}
}

View File

@@ -1,8 +1,6 @@
import React, { useLayoutEffect, useRef, useEffect } from "react";
import "./Popover.scss";
import { unstable_batchedUpdates } from "react-dom";
import { queryFocusableElements } from "../utils";
import { KEYS } from "../keys";
type Props = {
top?: number;
@@ -29,67 +27,17 @@ export const Popover = ({
}: Props) => {
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
useLayoutEffect(() => {
if (fitInViewport && popoverRef.current) {
const element = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect();
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
//Position correctly when clicked on rightmost part or the bottom part of viewport
if (x + width - offsetLeft > viewportWidth) {
element.style.left = `${viewportWidth - width - 10}px`;
element.style.left = `${viewportWidth - width}px`;
}
if (y + height - offsetTop > viewportHeight) {
element.style.top = `${viewportHeight - height}px`;
}
//Resize to fit viewport on smaller screens
if (height >= viewportHeight) {
element.style.height = `${viewportHeight - 20}px`;
element.style.top = "10px";
element.style.overflowY = "scroll";
}
if (width >= viewportWidth) {
element.style.width = `${viewportWidth}px`;
element.style.left = "0px";
element.style.overflowX = "scroll";
}
}
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]);

View File

@@ -82,10 +82,6 @@
}
}
&-warning {
color: $oc-red-6;
}
&-note {
padding: 1em;
font-style: italic;

View File

@@ -295,11 +295,6 @@ const PublishLibrary = ({
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
const shouldRenderForm = !!libraryItems.length;
const containsPublishedItems = libraryItems.some(
(item) => item.status === "published",
);
return (
<Dialog
onCloseRequest={onDialogClose}
@@ -334,11 +329,6 @@ const PublishLibrary = ({
<div className="publish-library-note">
{t("publishDialog.noteItems")}
</div>
{containsPublishedItems && (
<span className="publish-library-note publish-library-warning">
{t("publishDialog.republishWarning")}
</span>
)}
{renderLibraryItems()}
<div className="publish-library__fields">
<label>

View File

@@ -1,22 +0,0 @@
@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

@@ -1,46 +0,0 @@
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

@@ -3,24 +3,11 @@
.excalidraw {
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
margin: 0.3rem;
svg {
width: 100%;
height: 100%;
@@ -53,7 +40,7 @@
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
right: 1.3rem;
.ToolIcon__icon {
margin: 0;

View File

@@ -45,11 +45,6 @@ const SingleLibraryItem = ({
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,14 +212,16 @@
}
}
.ToolIcon.ToolIcon__library {
top: calc(var(--sat) + 100px);
top: 100px;
}
.ToolIcon.ToolIcon__lock {
top: calc(var(--sat) + 60px);
margin-inline-end: 0;
top: 60px;
}
.ToolIcon.ToolIcon__penMode {
top: calc(var(--sat) + 140px);
margin-inline-end: 0;
top: 140px;
}
}

View File

@@ -1,5 +1,26 @@
@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 {
.App-toolbar-container {
@@ -32,6 +53,7 @@
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}

View File

@@ -2,7 +2,7 @@
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: fixed;
position: absolute;
z-index: 1000;
padding: 8px;

View File

@@ -2,51 +2,17 @@ import "./UserList.scss";
import React from "react";
import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { ActionManager } from "../actions/manager";
export const UserList: React.FC<{
type UserListProps = {
children: React.ReactNode;
className?: string;
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 (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars}
{children}
</div>
);
};

View File

@@ -108,14 +108,14 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib",
} as const;
export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
export const EXPORT_SOURCE = window.location.origin;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
@@ -154,19 +154,9 @@ 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_LANDSCAPE = 1000;
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;
@@ -202,7 +192,3 @@ export const VERTICAL_ALIGN = {
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;

View File

@@ -0,0 +1,42 @@
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,6 +350,7 @@
align-items: flex-start;
cursor: default;
pointer-events: none !important;
z-index: 100;
:root[dir="ltr"] & {
left: 0.25rem;
@@ -390,7 +391,6 @@
.App-menu__left {
overflow-y: auto;
box-shadow: var(--shadow-island);
}
.dropdown-select {
@@ -449,7 +449,6 @@
bottom: 30px;
transform: translateX(-50%);
padding: 10px 20px;
pointer-events: all;
}
.help-icon {
@@ -568,22 +567,6 @@
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 {

View File

@@ -6,32 +6,8 @@
}
}
@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)";
$right-sidebar-width: "302px";
:export {
themeFilter: unquote($theme-filter);
rightSidebarWidth: unquote($right-sidebar-width);
}

View File

@@ -8,7 +8,7 @@ import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types";
@@ -123,79 +123,48 @@ export const isSupportedImageFile = (
);
};
export const loadSceneOrLibraryFromBlob = async (
blob: Blob | File,
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
) => {
const contents = await parseFileContents(blob);
try {
const data = JSON.parse(contents);
if (isValidExcalidrawData(data)) {
return {
type: MIME_TYPES.excalidraw,
data: restore(
{
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(
data.elements || [],
localAppState,
null,
)
: {}),
},
files: data.files,
},
localAppState,
localElements,
),
};
} else if (isValidLibrary(data)) {
return {
type: MIME_TYPES.excalidrawlib,
data,
};
if (!isValidExcalidrawData(data)) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
throw new Error(t("alerts.couldNotLoadInvalidFile"));
const result = restore(
{
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null)
: {}),
},
files: data.files,
},
localAppState,
localElements,
);
return result;
} catch (error: any) {
console.error(error.message);
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
};
export const loadFromBlob = async (
export const loadLibraryFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
) => {
const ret = await loadSceneOrLibraryFromBlob(
blob,
localAppState,
localElements,
fileHandle,
);
if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
return ret.data;
};
export const parseLibraryJSON = (
json: string,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
const data: ImportedLibraryData | undefined = JSON.parse(json);
const contents = await parseFileContents(blob);
const data: ImportedLibraryData | undefined = JSON.parse(contents);
if (!isValidLibrary(data)) {
throw new Error("Invalid library");
}
@@ -203,13 +172,6 @@ export const parseLibraryJSON = (
return restoreLibraryItems(libraryItems, defaultStatus);
};
export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
};
export const canvasToBlob = async (
canvas: HTMLCanvasElement,
): Promise<Blob> => {
@@ -238,7 +200,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
try {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-1",
await blobToArrayBuffer(file),
await file.arrayBuffer(),
);
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
} catch (error: any) {
@@ -327,125 +289,3 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
type: MIME_TYPES.svg,
}) as File & { type: typeof MIME_TYPES.svg };
};
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
return { file: file ? await normalizeFile(file) : null, fileHandle };
};
export const getFileHandle = async (
event: React.DragEvent<HTMLDivElement>,
): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) {
try {
const item = event.dataTransfer.items[0];
const handle: FileSystemHandle | null =
(await (item as any).getAsFileSystemHandle()) || null;
return handle;
} catch (error: any) {
console.warn(error.name, error.message);
return null;
}
}
return null;
};
/**
* attemps to detect if a buffer is a valid image by checking its leading bytes
*/
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
null;
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
// uint8 leading bytes
const headerBytes = {
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
png: "137 80 78 71 13 10 26 10 ",
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
// jpg is a bit wonky. Checking the first three bytes should be enough,
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
jpg: "255 216 255 ",
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
gif: "71 73 70 56 57 97 ",
};
if (first8Bytes === headerBytes.png) {
mimeType = MIME_TYPES.png;
} else if (first8Bytes.startsWith(headerBytes.jpg)) {
mimeType = MIME_TYPES.jpg;
} else if (first8Bytes.startsWith(headerBytes.gif)) {
mimeType = MIME_TYPES.gif;
}
return mimeType;
};
export const createFile = (
blob: File | Blob | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>,
name: string | undefined,
) => {
return new File([blob], name || "", {
type: mimeType,
});
};
/** attemps to detect correct mimeType if none is set, or if an image
* has an incorrect extension.
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
export const normalizeFile = async (file: File) => {
if (!file.type) {
if (file?.name?.endsWith(".excalidrawlib")) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.excalidrawlib,
file.name,
);
} else if (file?.name?.endsWith(".excalidraw")) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.excalidraw,
file.name,
);
} else {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType) {
file = createFile(buffer, mimeType, file.name);
}
}
// when the file is an image, make sure the extension corresponds to the
// actual mimeType (this is an edge case, but happens sometime)
} else if (isSupportedImageFile(file)) {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType && mimeType !== file.type) {
file = createFile(buffer, mimeType, file.name);
}
}
return file;
};
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};

View File

@@ -1,5 +1,4 @@
import { ENCRYPTION_KEY_BITS } from "../constants";
import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
@@ -59,7 +58,7 @@ export const encryptData = async (
: data instanceof Uint8Array
? data
: data instanceof Blob
? await blobToArrayBuffer(data)
? await data.arrayBuffer()
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and

View File

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

View File

@@ -3,12 +3,28 @@ import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob";
// -----------------------------------------------------------------------------
// PNG
// -----------------------------------------------------------------------------
const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const getTEXtChunk = async (
blob: Blob,
): Promise<{ keyword: string; text: string } | null> => {

View File

@@ -9,7 +9,7 @@ import {
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
import { isImageFileHandle, loadFromBlob } from "./blob";
import {
ExportedDataState,
@@ -17,6 +17,7 @@ import {
ExportedLibraryData,
ImportedLibraryData,
} from "./types";
import Library from "./library";
/**
* Strips out files which are only referenced by deleted elements
@@ -92,18 +93,13 @@ export const loadFromJSON = async (
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
) => {
const file = await fileOpen({
const blob = await fileOpen({
description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"],
});
return loadFromBlob(
await normalizeFile(file),
localAppState,
localElements,
file.handle,
);
return loadFromBlob(blob, localAppState, localElements);
};
export const isValidExcalidrawData = (data?: {
@@ -151,3 +147,15 @@ export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
},
);
};
export const importLibraryFromJSON = async (library: Library) => {
const blob = await fileOpen({
description: "Excalidraw library files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
/*
extensions: [".json", ".excalidrawlib"],
*/
});
await library.importLibrary(blob);
};

View File

@@ -1,26 +1,17 @@
import { loadLibraryFromBlob } from "./blob";
import {
LibraryItems,
LibraryItem,
ExcalidrawImperativeAPI,
LibraryItemsSource,
} from "../types";
import { LibraryItems, LibraryItem } from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { ImportedDataState } from "./types";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors";
import { isPromiseLike } from "../utils";
import { t } from "../i18n";
import { useEffect, useRef } from "react";
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
export const libraryItemsAtom = atom<
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
| { status: "loaded"; libraryItems: LibraryItems }
>({ status: "loaded", libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems));
@@ -49,28 +40,12 @@ const isUniqueItem = (
});
};
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
class Library {
/** latest libraryItems */
/** cache for currently active promise when initializing/updating libaries
asynchronously */
private libraryItemsPromise: Promise<LibraryItems> | null = null;
/** last resolved libraryItems */
private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private app: App;
@@ -78,359 +53,96 @@ class Library {
this.app = app;
}
private updateQueue: Promise<LibraryItems>[] = [];
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
resetLibrary = async () => {
this.saveLibrary([]);
};
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, {
status: "loading",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
} else {
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
try {
this.app.props.onLibraryChange?.(
cloneLibraryItems(this.lastLibraryItems),
);
} catch (error) {
console.error(error);
}
}
};
/** imports library (currently merges, removing duplicates) */
async importLibrary(
library:
| Blob
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished",
) {
return this.saveLibrary(
new Promise<LibraryItems>(async (resolve, reject) => {
try {
let libraryItems: LibraryItems;
if (library instanceof Blob) {
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
} else {
libraryItems = restoreLibraryItems(await library, defaultStatus);
}
resetLibrary = () => {
return this.setLibrary([]);
};
const existingLibraryItems = this.lastLibraryItems;
/**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
const filteredItems = [];
for (const item of libraryItems) {
if (isUniqueItem(existingLibraryItems, item)) {
filteredItems.push(item);
}
}
resolve([...filteredItems, ...existingLibraryItems]);
} catch (error) {
reject(new Error(t("errors.importLibraryError")));
}
}),
);
}
loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
try {
const libraryItems = await (this.getLastUpdateTask() ||
this.lastLibraryItems);
if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
}
resolve(
cloneLibraryItems(
await (this.libraryItemsPromise || this.lastLibraryItems),
),
);
} catch (error) {
return resolve(this.lastLibraryItems);
}
});
};
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
// a slight overhead (always restoring library items). For internal use
// where merging isn't needed, use `library.setLibrary()` directly.
updateLibrary = async ({
libraryItems,
prompt = false,
merge = false,
openLibraryMenu = false,
defaultStatus = "unpublished",
}: {
libraryItems: LibraryItemsSource;
merge?: boolean;
prompt?: boolean;
openLibraryMenu?: boolean;
defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => {
if (openLibraryMenu) {
this.app.setState({ isLibraryOpen: true });
}
return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => {
try {
const source = await (typeof libraryItems === "function"
? libraryItems(this.lastLibraryItems)
: libraryItems);
let nextItems;
if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus);
} else {
nextItems = restoreLibraryItems(source, defaultStatus);
}
if (
!prompt ||
window.confirm(
t("alerts.confirmAddLibrary", {
numShapes: nextItems.length,
}),
)
) {
if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
} else {
resolve(nextItems);
}
} else {
reject(new AbortError());
}
} catch (error: any) {
reject(error);
}
});
}).finally(() => {
this.app.focusContainer();
});
};
setLibrary = (
/**
* LibraryItems that will replace current items. Can be a function which
* will be invoked after all previous tasks are resolved
* (this is the prefered way to update the library to avoid race conditions,
* but you'll want to manually merge the library items in the callback
* - which is what we're doing in Library.importLibrary()).
*
* If supplied promise is rejected with AbortError, we swallow it and
* do not update the library.
*/
libraryItems:
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
} catch (error: any) {
reject(error);
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
const prevLibraryItems = this.lastLibraryItems;
try {
let nextLibraryItems;
if (isPromiseLike(items)) {
const promise = items.then((items) => cloneLibraryItems(items));
this.libraryItemsPromise = promise;
jotaiStore.set(libraryItemsAtom, {
status: "loading",
promise,
libraryItems: null,
});
nextLibraryItems = await promise;
} else {
nextLibraryItems = cloneLibraryItems(items);
}
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
this.lastLibraryItems = nextLibraryItems;
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: nextLibraryItems,
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
await this.app.props.onLibraryChange?.(
cloneLibraryItems(nextLibraryItems),
);
} catch (error: any) {
this.lastLibraryItems = prevLibraryItems;
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: prevLibraryItems,
});
throw error;
}
};
}
export default Library;
export const distributeLibraryItemsOnSquareGrid = (
libraryItems: LibraryItems,
) => {
const PADDING = 50;
const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
const resElements: ExcalidrawElement[] = [];
const getMaxHeightPerRow = (row: number) => {
const maxHeight = libraryItems
.slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
.reduce((acc, item) => {
const { height } = getCommonBoundingBox(item.elements);
return Math.max(acc, height);
}, 0);
return maxHeight;
};
const getMaxWidthPerCol = (targetCol: number) => {
let index = 0;
let currCol = 0;
let maxWidth = 0;
for (const item of libraryItems) {
if (index % ITEMS_PER_ROW === 0) {
currCol = 0;
}
if (currCol === targetCol) {
const { width } = getCommonBoundingBox(item.elements);
maxWidth = Math.max(maxWidth, width);
}
index++;
currCol++;
}
return maxWidth;
};
let colOffsetX = 0;
let rowOffsetY = 0;
let maxHeightCurrRow = 0;
let maxWidthCurrCol = 0;
let index = 0;
let col = 0;
let row = 0;
for (const item of libraryItems) {
if (index && index % ITEMS_PER_ROW === 0) {
rowOffsetY += maxHeightCurrRow + PADDING;
colOffsetX = 0;
col = 0;
row++;
}
if (col === 0) {
maxHeightCurrRow = getMaxHeightPerRow(row);
}
maxWidthCurrCol = getMaxWidthPerCol(col);
const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
const offsetCenterX = (maxWidthCurrCol - width) / 2;
const offsetCenterY = (maxHeightCurrRow - height) / 2;
resElements.push(
// eslint-disable-next-line no-loop-func
...item.elements.map((element) => ({
...element,
x:
element.x +
// offset for column
colOffsetX +
// offset to center in given square grid
offsetCenterX -
// subtract minX so that given item starts at 0 coord
minX,
y:
element.y +
// offset for row
rowOffsetY +
// offset to center in given square grid
offsetCenterY -
// subtract minY so that given item starts at 0 coord
minY,
})),
);
colOffsetX += maxWidthCurrCol + PADDING;
index++;
col++;
}
return resElements;
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
new URLSearchParams(window.location.hash.slice(1)).get(
URL_HASH_KEYS.addLibrary,
) ||
// legacy, kept for compat reasons
new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary);
const idToken = libraryUrl
? new URLSearchParams(window.location.hash.slice(1)).get("token")
: null;
return libraryUrl ? { libraryUrl, idToken } : null;
};
export const useHandleLibrary = ({
excalidrawAPI,
getInitialLibraryItems,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
getInitialLibraryItems?: () => LibraryItemsSource;
}) => {
const getInitialLibraryRef = useRef(getInitialLibraryItems);
useEffect(() => {
if (!excalidrawAPI) {
return;
}
const importLibraryFromURL = ({
libraryUrl,
idToken,
}: {
libraryUrl: string;
idToken: string | null;
}) => {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
const hash = new URLSearchParams(window.location.hash.slice(1));
hash.delete(URL_HASH_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
const query = new URLSearchParams(window.location.search);
query.delete(URL_QUERY_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
}
excalidrawAPI.updateLibrary({
libraryItems: new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
reject(error);
}
}),
prompt: idToken !== excalidrawAPI.id,
merge: true,
defaultStatus: "published",
openLibraryMenu: true,
});
};
const onHashChange = (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
event.stopImmediatePropagation();
// If hash changed and it contains library url, import it and replace
// the url to its previous state (important in case of collaboration
// and similar).
// Using history API won't trigger another hashchange.
window.history.replaceState({}, "", event.oldURL);
importLibraryFromURL(libraryUrlTokens);
}
};
// -------------------------------------------------------------------------
// ------ init load --------------------------------------------------------
if (getInitialLibraryRef.current) {
excalidrawAPI.updateLibrary({
libraryItems: getInitialLibraryRef.current(),
});
}
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
importLibraryFromURL(libraryUrlTokens);
}
// --------------------------------------------------------- init load -----
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
};
}, [excalidrawAPI]);
};

View File

@@ -26,7 +26,7 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getUpdatedTimestamp } from "../utils";
import { arrayToMap } from "../utils";
type RestoredAppState = Omit<
@@ -48,7 +48,6 @@ export const AllowedExcalidrawActiveTools: Record<
arrow: true,
freedraw: true,
eraser: false,
custom: true,
};
export type RestoredDataState = {
@@ -199,7 +198,6 @@ const restoreElement = (
y,
});
}
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
@@ -257,7 +255,6 @@ export const restoreAppState = (
? localValue
: defaultValue;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
@@ -266,15 +263,11 @@ export const restoreAppState = (
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool: {
...updateActiveTool(
defaultAppState,
nextAppState.activeTool.type &&
AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
? nextAppState.activeTool
: { type: "selection" },
),
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
? nextAppState.activeTool.type ?? "selection"
: "selection",
},
// Migrates from previous version where appState.zoom was a number
zoom:
@@ -283,11 +276,6 @@ export const restoreAppState = (
value: appState.zoom as NormalizedZoomValue,
}
: 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

@@ -1,10 +1,5 @@
import { ExcalidrawElement } from "../element/types";
import {
AppState,
BinaryFiles,
LibraryItems,
LibraryItems_anyVersion,
} from "../types";
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
import type { cleanAppStateForExport } from "../appState";
import { VERSIONS } from "../constants";
@@ -24,7 +19,7 @@ export interface ImportedDataState {
elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems_anyVersion;
libraryItems?: LibraryItems | LibraryItems_v1;
files?: BinaryFiles;
}

View File

@@ -47,20 +47,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
return appState.isBindingEnabled;
};
const getNonDeletedElements = (
scene: Scene,
ids: readonly ExcalidrawElement["id"][],
): NonDeleted<ExcalidrawElement>[] => {
const result: NonDeleted<ExcalidrawElement>[] = [];
ids.forEach((id) => {
const element = scene.getNonDeletedElement(id);
if (element != null) {
result.push(element);
}
});
return result;
};
export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep",
@@ -88,17 +74,16 @@ export const bindOrUnbindLinearElement = (
const onlyUnbound = Array.from(unboundFromElementIds).filter(
(id) => !boundToElementIds.has(id),
);
getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach(
(element) => {
Scene.getScene(linearElement)!
.getNonDeletedElements(onlyUnbound)
.forEach((element) => {
mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
),
});
},
);
});
};
const bindOrUnbindLinearElementEdge = (
@@ -268,7 +253,7 @@ export const getHoveredElementForBinding = (
scene: Scene,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
scene.getNonDeletedElements(),
scene.getElements(),
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords),
@@ -320,48 +305,46 @@ export const updateBoundElements = (
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
Scene.getScene(changedElement)!
.getNonDeletedElements(boundLinearElements.map((el) => el.id))
.forEach((element) => {
if (!isLinearElement(element)) {
return;
}
getNonDeletedElements(
Scene.getScene(changedElement)!,
boundLinearElements.map((el) => el.id),
).forEach((element) => {
if (!isLinearElement(element)) {
return;
}
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElements are stale
if (!doesNeedUpdate(element, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, { startBinding, endBinding });
return;
}
updateBoundPoint(
element,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
element,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElements are stale
if (!doesNeedUpdate(element, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, { startBinding, endBinding });
return;
}
updateBoundPoint(
element,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
element,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
};
const doesNeedUpdate = (
@@ -524,7 +507,7 @@ const getElligibleElementsForBindableElementAndWhere = (
bindableElement: NonDeleted<ExcalidrawBindableElement>,
): SuggestedPointBinding[] => {
return Scene.getScene(bindableElement)!
.getNonDeletedElements()
.getElements()
.map((element) => {
if (!isBindingElement(element, false)) {
return null;

View File

@@ -7,10 +7,9 @@ export const showSelectedShapeActions = (
elements: readonly NonDeletedExcalidrawElement[],
) =>
Boolean(
(!appState.viewModeEnabled &&
appState.activeTool.type !== "custom" &&
!appState.viewModeEnabled &&
(appState.editingElement ||
getSelectedElements(elements, appState).length ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser"))) ||
getSelectedElements(elements, appState).length,
appState.activeTool.type !== "eraser")),
);

View File

@@ -115,9 +115,6 @@ describe("textWysiwyg", () => {
height: textSize,
containerId: container.id,
});
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
h.elements = [container, text];
@@ -547,29 +544,6 @@ describe("textWysiwyg", () => {
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
});
it("should'nt bind text to container when not double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
// clicking somewhere on top left
mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toBe(null);
});
it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
expect(h.elements.length).toBe(1);

View File

@@ -283,14 +283,7 @@ export const textWysiwyg = ({
// using scrollHeight here since we need to calculate
// number of lines so cannot use editable.style.height
// as that gets updated below
// Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
const lines = editable.scrollHeight / getApproxLineHeight(font);
// auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for
// first line as well as setting height to "auto"
@@ -305,6 +298,7 @@ export const textWysiwyg = ({
font,
container!.width,
).split("\n").length;
// This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well
@@ -322,6 +316,8 @@ export const textWysiwyg = ({
}
editable.onkeydown = (event) => {
event.stopPropagation();
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionZoomIn);

View File

@@ -19,7 +19,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
}, STORAGE_SIZE_TIMEOUT);
type Props = {
setToast: (message: string) => void;
setToastMessage: (message: string) => void;
};
const CustomStats = (props: Props) => {
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
@@ -68,7 +68,7 @@ const CustomStats = (props: Props) => {
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setToast(t("toast.copyToClipboard"));
props.setToastMessage(t("toast.copyToClipboard"));
} catch {}
}}
title={t("stats.versionCopy")}

View File

@@ -6,7 +6,6 @@ export const LOAD_IMAGES_TIMEOUT = 500;
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)

View File

@@ -8,12 +8,10 @@ import {
ExcalidrawElement,
InitializedExcalidrawImageElement,
} from "../../element/types";
import {
getSceneVersion,
restoreElements,
} from "../../packages/excalidraw/index";
import { getSceneVersion } from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
import {
getFrame,
preventUnload,
resolvablePromise,
withBatchedUpdates,
@@ -32,9 +30,7 @@ import {
generateCollaborationLinkData,
getCollaborationLink,
getCollabServer,
getSyncableElements,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
isSavedToFirebase,
@@ -49,9 +45,12 @@ import {
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
import { UserIdleState } from "../../types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
import { trackEvent } from "../../analytics";
import { isInvisiblySmallElement } from "../../element";
import {
encodeFilesForUpload,
FileManager,
@@ -70,45 +69,52 @@ import {
import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
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 {
modalIsShown: boolean;
errorMessage: string;
username: string;
userState: UserIdleState;
activeRoomLink: string;
}
type CollabInstance = InstanceType<typeof Collab>;
type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI {
/** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
startCollaboration: CollabInstance["startCollaboration"];
stopCollaboration: CollabInstance["stopCollaboration"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void;
}
interface PublicProps {
interface Props {
excalidrawAPI: ExcalidrawImperativeAPI;
onRoomClose?: () => void;
}
type Props = PublicProps & { modalIsShown: boolean };
const {
Context: CollabContext,
Consumer: CollabContextConsumer,
Provider: CollabContextProvider,
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
class Collab extends PureComponent<Props, CollabState> {
export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"];
activeIntervalId: 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 lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
@@ -116,8 +122,10 @@ class Collab extends PureComponent<Props, CollabState> {
constructor(props: Props) {
super(props);
this.state = {
modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "",
};
this.portal = new Portal(this);
@@ -155,18 +163,6 @@ class Collab extends PureComponent<Props, CollabState> {
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
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 (
process.env.NODE_ENV === ENV.TEST ||
process.env.NODE_ENV === ENV.DEVELOPMENT
@@ -199,23 +195,19 @@ class Collab extends PureComponent<Props, CollabState> {
}
}
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
private setIsCollaborating = (isCollaborating: boolean) => {
jotaiStore.set(isCollaboratingAtom, isCollaborating);
};
isCollaborating = () => this._isCollaborating;
private onUnload = () => {
this.destroySocketClient({ isUnload: true });
};
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements(
const syncableElements = this.getSyncableElements(
this.getSceneElementsIncludingDeleted(),
);
if (
this.isCollaborating() &&
this._isCollaborating &&
(this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements))
) {
@@ -240,7 +232,7 @@ class Collab extends PureComponent<Props, CollabState> {
});
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
syncableElements: readonly ExcalidrawElement[],
) => {
try {
const savedData = await saveToFirebase(
@@ -259,36 +251,31 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
stopCollaboration = (keepRemoteState = true) => {
openPortal = async () => {
trackEvent("share", "room creation", `ui (${getFrame()})`);
return this.initializeSocketClient(null);
};
closePortal = () => {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
this.saveCollabRoomToFirebase(
getSyncableElements(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
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"))) {
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
// hack to ensure that we prefer we disregard any new browser state
// that could have been saved in other tabs while we were collaborating
resetBrowserStateVersions();
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
trackEvent("share", "room closed");
LocalData.fileStorage.reset();
this.props.onRoomClose?.();
const elements = this.excalidrawAPI
.getSceneElementsIncludingDeleted()
@@ -307,20 +294,20 @@ class Collab extends PureComponent<Props, CollabState> {
};
private destroySocketClient = (opts?: { isUnload: boolean }) => {
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
if (!opts?.isUnload) {
this.setIsCollaborating(false);
this.setState({
activeRoomLink: "",
});
this.collaborators = new Map();
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
});
this.setState({
activeRoomLink: "",
});
this._isCollaborating = false;
LocalData.resumeSave("collaboration");
}
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
};
private fetchImageFilesFromFirebase = async (scene: {
@@ -361,9 +348,7 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
private fallbackInitializationHandler: null | (() => any) = null;
startCollaboration = async (
private initializeSocketClient = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
if (this.portal.socket) {
@@ -386,23 +371,13 @@ class Collab extends PureComponent<Props, CollabState> {
const scenePromise = resolvablePromise<ImportedDataState | null>();
this.setIsCollaborating(true);
this._isCollaborating = true;
LocalData.pauseSave("collaboration");
const { default: socketIOClient } = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
);
const fallbackInitializationHandler = () => {
this.initializeRoom({
roomLinkData: existingRoomLinkData,
fetchScene: true,
}).then((scene) => {
scenePromise.resolve(scene);
});
};
this.fallbackInitializationHandler = fallbackInitializationHandler;
try {
const socketServerData = await getCollabServer();
@@ -415,8 +390,6 @@ class Collab extends PureComponent<Props, CollabState> {
roomId,
roomKey,
);
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
@@ -440,15 +413,18 @@ class Collab extends PureComponent<Props, CollabState> {
commitToHistory: true,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
}
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_INIT message
this.socketInitializationTimer = window.setTimeout(
fallbackInitializationHandler,
INITIAL_SCENE_UPDATE_TIMEOUT,
);
this.socketInitializationTimer = window.setTimeout(() => {
this.initializeRoom({
roomLinkData: existingRoomLinkData,
fetchScene: true,
});
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
// All socket listeners are moving to Portal
this.portal.socket.on(
@@ -553,12 +529,6 @@ class Collab extends PureComponent<Props, CollabState> {
}
| { fetchScene: false; roomLinkData?: null }) => {
clearTimeout(this.socketInitializationTimer!);
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
if (fetchScene && roomLinkData && this.portal.socket) {
this.excalidrawAPI.resetScene();
@@ -596,8 +566,6 @@ class Collab extends PureComponent<Props, CollabState> {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
remoteElements = restoreElements(remoteElements, null);
const reconciledElements = _reconcileElements(
localElements,
remoteElements,
@@ -703,17 +671,19 @@ class Collab extends PureComponent<Props, CollabState> {
};
setCollaborators(sockets: string[]) {
const collaborators: InstanceType<typeof Collab>["collaborators"] =
new Map();
for (const socketId of sockets) {
if (this.collaborators.has(socketId)) {
collaborators.set(socketId, this.collaborators.get(socketId)!);
} else {
collaborators.set(socketId, {});
this.setState((state) => {
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
new Map();
for (const socketId of sockets) {
if (this.collaborators.has(socketId)) {
collaborators.set(socketId, this.collaborators.get(socketId)!);
} else {
collaborators.set(socketId, {});
}
}
}
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators });
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators });
});
}
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
@@ -742,6 +712,7 @@ class Collab extends PureComponent<Props, CollabState> {
);
onIdleStateChange = (userState: UserIdleState) => {
this.setState({ userState });
this.portal.broadcastIdleChange(userState);
};
@@ -775,22 +746,18 @@ class Collab extends PureComponent<Props, CollabState> {
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
}, SYNC_FULL_SCENE_INTERVAL_MS);
queueSaveToFirebase = throttle(
() => {
if (this.portal.socketInitialized) {
this.saveCollabRoomToFirebase(
getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
}
},
SYNC_FULL_SCENE_INTERVAL_MS,
{ leading: false },
);
queueSaveToFirebase = throttle(() => {
if (this.portal.socketInitialized) {
this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
}
}, SYNC_FULL_SCENE_INTERVAL_MS);
handleClose = () => {
jotaiStore.set(collabDialogShownAtom, false);
this.setState({ modalIsShown: false });
};
setUsername = (username: string) => {
@@ -802,10 +769,42 @@ class Collab extends PureComponent<Props, CollabState> {
saveUsernameToLocalStorage(username);
};
render() {
const { username, errorMessage, activeRoomLink } = this.state;
onCollabButtonClick = () => {
this.setState({
modalIsShown: true,
});
};
const { modalIsShown } = this.props;
isSyncableElement = (element: ExcalidrawElement) => {
return element.isDeleted || !isInvisiblySmallElement(element);
};
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter((element) => this.isSyncableElement(element));
/** 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() {
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
return (
<>
@@ -815,8 +814,8 @@ class Collab extends PureComponent<Props, CollabState> {
activeRoomLink={activeRoomLink}
username={username}
onUsernameChange={this.onUsernameChange}
onRoomCreate={() => this.startCollaboration(null)}
onRoomDestroy={this.stopCollaboration}
onRoomCreate={this.openPortal}
onRoomDestroy={this.closePortal}
setErrorMessage={(errorMessage) => {
this.setState({ errorMessage });
}}
@@ -829,6 +828,11 @@ class Collab extends PureComponent<Props, CollabState> {
onClose={() => this.setState({ errorMessage: "" })}
/>
)}
<CollabContextProvider
value={{
api: this.getContextValue(),
}}
/>
</>
);
}
@@ -836,7 +840,7 @@ class Collab extends PureComponent<Props, CollabState> {
declare global {
interface Window {
collab: InstanceType<typeof Collab>;
collab: InstanceType<typeof CollabWrapper>;
}
}
@@ -847,11 +851,4 @@ if (
window.collab = window.collab || ({} as Window["collab"]);
}
const _Collab: React.FC<PublicProps> = (props) => {
const [collabDialogShown] = useAtom(collabDialogShownAtom);
return <Collab {...props} modalIsShown={collabDialogShown} />;
};
export default _Collab;
export type TCollabClass = Collab;
export default CollabWrapper;

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import { decompressData } from "../../data/encode";
import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
// private
// -----------------------------------------------------------------------------
@@ -128,18 +127,7 @@ const decryptElements = async (
return JSON.parse(decodedData);
};
class FirebaseSceneVersionCache {
private static cache = new WeakMap<SocketIOClient.Socket, number>();
static get = (socket: SocketIOClient.Socket) => {
return FirebaseSceneVersionCache.cache.get(socket);
};
static set = (
socket: SocketIOClient.Socket,
elements: readonly SyncableExcalidrawElement[],
) => {
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
};
}
const firebaseSceneVersionCache = new WeakMap<SocketIOClient.Socket, number>();
export const isSavedToFirebase = (
portal: Portal,
@@ -148,7 +136,7 @@ export const isSavedToFirebase = (
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements);
return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
}
// if no room exists, consider the room saved so that we don't unnecessarily
// prevent unload (there's nothing we could do at that point anyway)
@@ -193,7 +181,7 @@ export const saveFilesToFirebase = async ({
const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly SyncableExcalidrawElement[],
elements: readonly ExcalidrawElement[],
roomKey: string,
) => {
const sceneVersion = getSceneVersion(elements);
@@ -209,7 +197,7 @@ const createFirebaseSceneDocument = async (
export const saveToFirebase = async (
portal: Portal,
elements: readonly SyncableExcalidrawElement[],
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const { roomId, roomKey, socket } = portal;
@@ -241,18 +229,18 @@ export const saveToFirebase = async (
transaction.set(docRef, sceneDocument);
return {
elements,
sceneVersion: sceneDocument.sceneVersion,
reconciledElements: null,
};
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = getSyncableElements(
await decryptElements(prevDocData, roomKey),
);
const prevElements = await decryptElements(prevDocData, roomKey);
const reconciledElements = getSyncableElements(
reconcileElements(elements, prevElements, appState),
const reconciledElements = reconcileElements(
elements,
prevElements,
appState,
);
const sceneDocument = await createFirebaseSceneDocument(
@@ -263,14 +251,14 @@ export const saveToFirebase = async (
transaction.update(docRef, sceneDocument);
return {
elements,
reconciledElements,
sceneVersion: sceneDocument.sceneVersion,
};
});
FirebaseSceneVersionCache.set(socket, savedData.elements);
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
return { reconciledElements: savedData.reconciledElements };
return savedData;
};
export const loadFromFirebase = async (
@@ -287,12 +275,10 @@ export const loadFromFirebase = async (
return null;
}
const storedScene = doc.data() as FirebaseStoredScene;
const elements = getSyncableElements(
await decryptElements(storedScene, roomKey),
);
const elements = await decryptElements(storedScene, roomKey);
if (socket) {
FirebaseSceneVersionCache.set(socket, elements);
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements, null);

View File

@@ -7,7 +7,6 @@ import {
import { serializeAsJSON } from "../../data/json";
import { restore } from "../../data/restore";
import { ImportedDataState } from "../../data/types";
import { isInvisiblySmallElement } from "../../element/sizeHelpers";
import { isInitializedImageElement } from "../../element/typeChecks";
import { ExcalidrawElement, FileId } from "../../element/types";
import { t } from "../../i18n";
@@ -18,35 +17,10 @@ import {
UserIdleState,
} from "../../types";
import { bytesToHexString } from "../../utils";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
} from "../app_constants";
import { FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
export type SyncableExcalidrawElement = ExcalidrawElement & {
_brand: "SyncableExcalidrawElement";
};
export const isSyncableElement = (
element: ExcalidrawElement,
): element is SyncableExcalidrawElement => {
if (element.isDeleted) {
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
return true;
}
return false;
}
return !isInvisiblySmallElement(element);
};
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter((element) =>
isSyncableElement(element),
) as SyncableExcalidrawElement[];
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
@@ -134,16 +108,9 @@ export type 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) => {
const hash = new URL(link).hash;
const match = hash.match(RE_COLLAB_LINK);
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
if (match && match[2].length !== 22) {
window.alert(t("alerts.invalidEncryptionKey"));
return null;

View File

@@ -32,25 +32,3 @@
pointer-events: none;
}
}
.plus-button {
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
border: 1px solid var(--color-primary);
padding: 0.6em 0.7em;
border-radius: var(--space-factor);
color: var(--color-primary) !important;
margin: 8px;
text-decoration: none !important;
&:hover {
background-color: var(--color-primary);
color: white !important;
}
&:active {
background-color: var(--color-primary-darker);
}
}

View File

@@ -1,14 +1,14 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import {
APP_NAME,
COOKIES,
EVENT,
TITLE_TIMEOUT,
URL_HASH_KEYS,
VERSION_TIMEOUT,
} from "../constants";
import { loadFromBlob } from "../data/blob";
@@ -18,7 +18,7 @@ import {
NonDeletedExcalidrawElement,
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { Language, t } from "../i18n";
import {
Excalidraw,
defaultLang,
@@ -45,26 +45,20 @@ import {
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import Collab, {
import CollabWrapper, {
CollabAPI,
collabAPIAtom,
collabDialogShownAtom,
isCollaboratingAtom,
} from "./collab/Collab";
CollabContext,
CollabContextConsumer,
} from "./collab/CollabWrapper";
import { LanguageList } from "./components/LanguageList";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { restoreAppState, RestoredDataState } from "../data/restore";
import { Tooltip } from "../components/Tooltip";
import { shield } from "../components/icons";
@@ -78,20 +72,14 @@ import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
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";
window.EXCALIDRAW_THROTTLE_RENDER = true;
const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
languageUtils: {
formatLanguageCode: (langCode: Language["code"]) => langCode,
isWhitelisted: () => true,
},
checkWhitelist: false,
});
const initializeScene = async (opts: {
@@ -181,7 +169,7 @@ const initializeScene = async (opts: {
if (roomLinkData) {
return {
scene: await opts.collabAPI.startCollaboration(roomLinkData),
scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
isExternalScene: true,
id: roomLinkData.roomId,
key: roomLinkData.roomKey,
@@ -199,7 +187,7 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const PlusLPLinkJSX = (
const PlusLinkJSX = (
<p style={{ direction: "ltr", unicodeBidi: "embed" }}>
Introducing Excalidraw+
<br />
@@ -213,17 +201,6 @@ const PlusLPLinkJSX = (
</p>
);
const PlusAppLinkJSX = (
<a
href={`${process.env.REACT_APP_PLUS_APP}/#excalidraw-redirect`}
target="_blank"
rel="noreferrer"
className="plus-button"
>
Go to Excalidraw+
</a>
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
let currentLangCode = languageDetector.detect() || defaultLang.code;
@@ -253,16 +230,7 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [collabAPI] = useAtom(collabAPIAtom);
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
useHandleLibrary({
excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage,
});
const collabAPI = useContext(CollabContext)?.api;
useEffect(() => {
if (!collabAPI || !excalidrawAPI) {
@@ -333,46 +301,33 @@ const ExcalidrawWrapper = () => {
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
}
}
data.scene.libraryItems = getLibraryItemsFromStorage();
};
initializeScene({ collabAPI }).then(async (data) => {
initializeScene({ collabAPI }).then((data) => {
loadImages(data, /* isInitialLoad */ true);
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(),
),
});
initialStatePromiseRef.current.promise.resolve(data.scene);
});
const onHashChange = async (event: HashChangeEvent) => {
const onHashChange = (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) {
if (
collabAPI.isCollaborating() &&
!isCollaborationLink(window.location.href)
) {
collabAPI.stopCollaboration(false);
}
excalidrawAPI.updateScene({ appState: { isLoading: true } });
const hash = new URLSearchParams(window.location.hash.slice(1));
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
if (libraryUrl) {
// If hash changed and it contains library url, import it and replace
// the url to its previous state (important in case of collaboration
// and similar).
// Using history API won't trigger another hashchange.
window.history.replaceState({}, "", event.oldURL);
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
} else {
initializeScene({ collabAPI }).then((data) => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null),
commitToHistory: true,
appState: restoreAppState(data.scene.appState, null),
});
}
});
@@ -400,8 +355,6 @@ const ExcalidrawWrapper = () => {
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
});
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
});
collabAPI.setUsername(username || "");
@@ -513,17 +466,19 @@ const ExcalidrawWrapper = () => {
if (excalidrawAPI) {
let didChange = false;
let pendingImageElement = appState.pendingImageElement;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
const newElement = newElementWith(element, { status: "saved" });
if (newElement !== element) {
didChange = true;
didChange = true;
const newEl = newElementWith(element, { status: "saved" });
if (pendingImageElement === element) {
pendingImageElement = newEl;
}
return newElement;
return newEl;
}
return element;
});
@@ -531,6 +486,9 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
appState: {
pendingImageElement,
},
});
}
}
@@ -574,16 +532,17 @@ const ExcalidrawWrapper = () => {
if (isMobile) {
return null;
}
return (
<div
style={{
width: isExcalidrawPlusSignedUser ? "21ch" : "23ch",
width: "24ch",
fontSize: "0.7em",
textAlign: "center",
}}
>
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
{/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
{/* FIXME remove after 2021-05-20 */}
{PlusLinkJSX}
</div>
);
},
@@ -635,14 +594,12 @@ const ExcalidrawWrapper = () => {
marginTop: isTinyDevice ? 16 : undefined,
marginLeft: "auto",
marginRight: isTinyDevice ? "auto" : undefined,
padding: isExcalidrawPlusSignedUser ? undefined : "4px 2px",
border: isExcalidrawPlusSignedUser
? undefined
: "1px dashed #aaa",
padding: "4px 2px",
border: "1px dashed #aaa",
borderRadius: 12,
}}
>
{isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
{PlusLinkJSX}
</div>
</div>
);
@@ -660,7 +617,7 @@ const ExcalidrawWrapper = () => {
const renderCustomStats = () => {
return (
<CustomStats
setToast={(message) => excalidrawAPI!.setToast({ message })}
setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
/>
);
};
@@ -674,19 +631,23 @@ const ExcalidrawWrapper = () => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const onRoomClose = useCallback(() => {
LocalData.fileStorage.reset();
}, []);
return (
<div
style={{ height: "100%" }}
className={clsx("excalidraw-app", {
"is-collaborating": isCollaborating,
"is-collaborating": collabAPI?.isCollaborating(),
})}
>
<Excalidraw
ref={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
onCollabButtonClick={() => setCollabDialogShown(true)}
isCollaborating={isCollaborating}
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate}
UIOptions={{
canvasActions: {
@@ -720,7 +681,12 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange}
autoFocus={true}
/>
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
{excalidrawAPI && (
<CollabWrapper
excalidrawAPI={excalidrawAPI}
onRoomClose={onRoomClose}
/>
)}
{errorMessage && (
<ErrorDialog
message={errorMessage}
@@ -734,9 +700,9 @@ const ExcalidrawWrapper = () => {
const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => jotaiStore}>
<CollabContextConsumer>
<ExcalidrawWrapper />
</Provider>
</CollabContextConsumer>
</TopErrorBoundary>
);
};

4
src/global.d.ts vendored
View File

@@ -13,8 +13,6 @@ interface Window {
ClipboardItem: any;
__EXCALIDRAW_SHA__: string | undefined;
EXCALIDRAW_ASSET_PATH: string | undefined;
EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
gtag: Function;
}
@@ -36,8 +34,6 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type ValueOf<T> = T[keyof T];
type Merge<M, N> = Omit<M, keyof N> & N;
/** utility type to assert that the second type is a subtype of the first type.

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